documentation · 01

Markup widget — install & use

A step-by-step tutorial for getting the @pixelmatters/markup widget running in any web app. Designed to be readable end-to-end by engineers, designers, product managers, and LLMs.

What you get: a compact toolbar in the corner of your app. Anyone who opens the page can click its comment button, drop a pin anywhere, and leave a threaded comment (with optional annotated screenshot). They can @-mention teammates, and signed-in users read their mention notifications from the toolbar's inbox. Threads stream into the Markup dashboard in real time.


1. What you need before you start

You needWhere to get it
A Markup accountMarkup dashboard — sign in with Google
A ProjectDashboard → + New project
An API keyProject → Settings → API Keys → New key (the raw key is shown once — copy it)
Your API URLProject → Settings → Install — looks like https://<your-deployment>.convex.site
(Production) Your host domainProject → Settings → Domains — add app.example.com, *.staging.example.com, etc. Production deployments require every host on the allowlist; a self-hosted dev deployment can opt into the localhost bypass with MARKUP_ALLOW_LOCALHOST=1

You'll plug apiUrl and apiKey into the widget. That's it — there's no global CSS to import and no provider to wrap your app in.


2. AI prompt — paste into your assistant

If you're using Claude, ChatGPT, Cursor, or any other LLM, paste the block below. It's self-contained and gives the model exactly what it needs to wire the widget into your codebase. Skip ahead to section 3 if you'd rather install by hand.

You are helping me install the **`@pixelmatters/markup`** feedback widget into my web app.

## What it is

A drop-in feedback widget published on npm as `@pixelmatters/markup`. It mounts a compact toolbar that lets users pin threaded comments (and optional annotated screenshots) anywhere on the page. It runs inside a shadow DOM so it doesn't affect host CSS.

## My credentials

- `apiUrl`: `https://<MY_DEPLOYMENT>.convex.site` ← replace with the value from Markup dashboard → Settings → Install
- `apiKey`: `markup_...` ← replace with a key from Markup dashboard → Settings → API Keys
  Store these in environment variables (e.g. `VITE_MARKUP_API_URL`, `VITE_MARKUP_API_KEY`, or the equivalent for my framework). Do not hardcode them.

## API

```ts
import { init, destroy } from '@pixelmatters/markup'

init({
  apiUrl: string, // required
  apiKey: string, // required
  position?: 'bottom-right' | 'bottom-left' | 'bottom-center', // default 'bottom-right'
  theme?: 'light' | 'dark' | 'auto', // default 'auto'
  dashboardUrl?: string, // optional: adds an "Account →" link to the identity menu
  screenshots?: {
    enabled?: boolean, // default true
    strictScrub?: boolean, // default false — also masks every input/select/textarea
    redactSelector?: string, // extra CSS selector to mask
  },
}) // returns a destroy() function — call it on unmount / logout / route teardown
```

There is no `fab` option any more — it's accepted, ignored, and warns once. Drop it if you find one in my config.

There is no framework-specific entrypoint — call `init()` from your
framework's mount hook (`useEffect`, `onMounted`, `onMount`, …) and call
the returned `destroy` on cleanup. Snippets for React, Vue, and Solid
are below.

For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the version**:

```html
<script type="module">
  import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.3'

  init({
    apiUrl: '...',
    apiKey: '...',
    position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
    theme: 'auto', // optional: 'auto' | 'light' | 'dark'
  })
</script>
```

If inline JS is disallowed (some CMS editors), use the auto-init `<script src=…>` form with `data-*` attributes (`data-markup-widget="true"` is required):

```html
<script
  type="module"
  src="https://esm.sh/@pixelmatters/markup@1.18.3"
  data-markup-widget="true"
  data-api-url="..."
  data-api-key="..."
  data-position="bottom-right"
></script>
```

## Your task

1. Detect my framework (React, Vue, Svelte, Next.js, plain HTML, etc.) by inspecting the project.
2. Install `@pixelmatters/markup` with the package manager already in use (pnpm/npm/yarn).
3. Wire the widget into the **root layout / app shell** so it shows on every page.
4. Read `apiUrl` and `apiKey` from environment variables; create `.env.example` entries and update `.gitignore` if needed.
5. For SPAs, ensure the widget is mounted once at the root (not per route) and unmounted via `destroy()` on logout.
6. Show me a diff of the changes and a one-line note on how to verify (e.g. "run dev server, click the comment button on the toolbar in the bottom-right").

Constraints:

- Do **not** add CSS imports or provider components — the widget needs neither.
- Do **not** hardcode the key.
- If the project has a CSP: add `https://<MY_DEPLOYMENT>.convex.site` and `wss://<MY_DEPLOYMENT>.convex.cloud` to `connect-src`, `blob:` and `data:` (plus the host of our profile pictures) to `img-src`, and `'unsafe-inline'` to `style-src`. Add `https://esm.sh` to `script-src` only if I'm using the `<script>` tag path.

3. Pick an install path

Three ways to add the widget. Pick the one that matches your stack.

Path A — Drop-in <script> tag (no build step)

Best for static sites, marketing pages, Webflow, WordPress, or any HTML you can edit directly.

Paste this just before </body>:

html
<script type="module">
  // Pin the exact version — esm.sh resolves it from npm
  import { init } from 'https://esm.sh/@pixelmatters/markup@1.18.3'
  // or
  // import { init } from 'https://esm.run/@pixelmatters/markup@1.18.3'

  init({
    apiUrl: 'https://your-deployment.convex.site',
    apiKey: 'markup_...',
    position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
    theme: 'auto', // optional: 'auto' | 'light' | 'dark'
  })
</script>

Pin the version. A bare @pixelmatters/markup URL resolves to whatever's latest on npm — a future major release will break your page silently. Always pin (@pixelmatters/markup@1.18.3).

When inline JS isn't allowed

Some CMS / page-builder editors only let you paste a <script src=…> tag, no inline code. For those, use the auto-init form — config travels via data-* attributes:

html
<script
  type="module"
  src="https://esm.sh/@pixelmatters/markup@1.18.3"
  data-markup-widget="true"
  data-api-url="https://your-deployment.convex.site"
  data-api-key="markup_..."
  data-position="bottom-right"
  data-theme="auto"
></script>

data-markup-widget="true" is required — it's how the bootstrap finds its own <script> tag (since document.currentScript is null for type="module").

Path B — Vanilla JS / TypeScript (any bundler)

shell
# pnpm
pnpm add @pixelmatters/markup
# yarn
yarn add @pixelmatters/markup
# npm
npm install @pixelmatters/markup
ts
import { init } from '@pixelmatters/markup'

const stop = init({
  apiUrl: 'https://your-deployment.convex.site',
  apiKey: 'markup_...',
  position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
  theme: 'auto', // optional: 'auto' | 'light' | 'dark'
})

// Tear down on logout / SPA route change / unmount:
stop()

Path C — React, Vue, or SolidJS

There's no framework-specific entrypoint. init() is plain JS — drop it into your framework's mount hook so it runs once at the root, and call the returned destroy on unmount.

React

tsx
import { useEffect } from 'react'
import { init } from '@pixelmatters/markup'

export default function App() {
  useEffect(() => {
    return init({
      apiUrl: import.meta.env.VITE_MARKUP_API_URL,
      apiKey: import.meta.env.VITE_MARKUP_API_KEY,
      position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
      theme: 'auto', // optional: 'auto' | 'light' | 'dark'
    })
  }, [])

  return <>{/* your app */}</>
}

Vue 3

vue
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import { init } from '@pixelmatters/markup'

let stop: (() => void) | undefined
onMounted(() => {
  stop = init({
    apiUrl: import.meta.env.VITE_MARKUP_API_URL,
    apiKey: import.meta.env.VITE_MARKUP_API_KEY,
    position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
    theme: 'auto', // optional: 'auto' | 'light' | 'dark'
  })
})
onBeforeUnmount(() => stop?.())
</script>

SolidJS

tsx
import { onMount, onCleanup } from 'solid-js'
import { init } from '@pixelmatters/markup'

export default function App() {
  onMount(() => {
    const stop = init({
      apiUrl: import.meta.env.VITE_MARKUP_API_URL,
      apiKey: import.meta.env.VITE_MARKUP_API_KEY,
      position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
      theme: 'auto', // optional: 'auto' | 'light' | 'dark'
    })
    onCleanup(stop)
  })

  return <>{/* your app */}</>
}

Tip — keep keys out of the repo. Store apiUrl and apiKey in environment variables (VITE_MARKUP_API_URL, VITE_MARKUP_API_KEY, etc.). The widget key is a public key (it's bound to your domain allowlist), but rotating it via env vars is still cleaner than committing it.


4. Configuration reference

OptionTypeDefaultDescription
apiUrlstringrequiredYour Convex deployment site URL (https://*.convex.site)
apiKeystringrequiredProject API key minted in the dashboard
position'bottom-right' | 'bottom-left' | 'bottom-center''bottom-right'Initial placement for the toolbar. Users can move it with the Position picker in the overflow menu
theme'light' | 'dark' | 'auto''auto''auto' follows the host's prefers-color-scheme. A user's pick in the overflow menu persists and outranks this option on every later init()
screenshotsScreenshotsConfigcapture enabledCapture toggle and PII-scrub knobs — see §9
dashboardUrlstringAdds an Account → link to the identity menu for signed-in users. Mostly for self-hosters, whose dashboard origin the widget can't infer

init(config) is idempotent — calling it twice with the same config is a no-op; calling it with new values tears down the old instance first. It returns a destroy() function.

The fab option from before 1.15.0 is deprecated and ignored — the floating action button became a toolbar pill, which has no variants. Passing it logs a one-time console warning; remove it when convenient.

What's on the toolbar

ControlWhat it does
CommentArms placement — the next click on the page drops a pin. cmd/ctrl + click hides the whole widget instead
InboxMention notifications for this project, with an unread badge. Signed-in users only
Pins (eye)Hides or shows every pin without hiding the toolbar
IdentityAvatar button — sign in, or (once signed in) name, email, Account →, and Sign out. Anonymous visitors also get "Forget me on this site"
Overflow ()Appearance (Light / Dark / Auto), Position (left / center / right), an Auto-capture screenshots toggle, Privacy & data, Keyboard shortcuts, Hide for this session, and the widget version

Privacy & data and Keyboard shortcuts open panels above the toolbar. The privacy panel is written for the person leaving feedback and describes the session in front of them — who they're posting as, what a comment sends, what's kept in this site's storage, whether captures are on, and the one host the widget talks to — plus "Forget me on this site" for anonymous visitors. Nothing to configure; it reads the live runtime.

Handy shortcuts: c starts a markup, @ opens the mention picker in a composer, cmd/ctrl + enter posts, cmd/ctrl + . toggles the whole widget, and esc backs out of whatever is open. The Keyboard shortcuts panel has the full list — it's reachable from the menu only, so the widget isn't claiming a plain key your app may already use.


5. Try it — a 60-second smoke test

  1. Drop the snippet from Path A into a blank index.html.
  2. Open the file with a local server (e.g. npx serve .). Production deployments don't auto-allow localhost; add localhost to Settings → Domains for a quick test, or point apiUrl at a self-hosted dev deployment with MARKUP_ALLOW_LOCALHOST=1.
  3. Click the comment button on the toolbar in the bottom-right.
  4. Click anywhere on the page → write a comment → submit.
  5. Open your project in the dashboard — the thread is there.

If nothing appears, jump to Troubleshooting below.


6. How it works (in one diagram)

your app
   │  embeds @pixelmatters/markup (Preact, runs inside an open shadow DOM)
   ▼
widget runtime ──► POST/GET /widget/* (x-markup-api-key + Origin) ──► Convex
                                                                       │
                                                                       ▼
                                                            real-time dashboard
  • Style isolation: the widget mounts inside a shadow root (:host { all: initial }). Your CSS can't bleed in; the widget's CSS can't bleed out.
  • Pin re-anchoring: every pin stores a CSS selector and a viewport-fraction fallback. If the selector doesn't match on first render (e.g. an SPA route is still loading), the pin renders dimmed at the fallback position and a MutationObserver on document.body re-queries selectors on each DOM batch until every pin resolves — at which point the observer detaches. Zero steady-state cost on a fully-rendered page.
  • SPA-aware: the widget patches history.pushState / replaceState and listens to popstate, so threads refresh on route changes.
  • Two origins: comments, identity, screenshots, and error reports go to https://<deployment>.convex.site; live thread updates arrive over a WebSocket to wss://<deployment>.convex.cloud, which the widget derives from apiUrl. A CSP needs both under connect-src.
  • Identity: anonymous by default. Signed-in authors show their profile picture on comments and on the toolbar; everyone else gets initials, which is also the fallback when a host CSP blocks the image. Comments written by an AI agent through Markup's MCP server carry a bot badge — they're posted under a team member's name, so the badge is the only way to tell.

7. Troubleshooting

SymptomLikely causeFix
401 Unauthorized in the network tabWrong / revoked keyMint a fresh key in Settings → API Keys
403 origin not allowedHost domain isn't in the project's allowlistSettings → Domains → add the domain (or *.staging.example.com)
Toolbar doesn't appearAuto-init <script> missing data-markup-widget="true", inline init() not called, or CSP blocks esm.shAdd the attribute, call init(), or allow the script origin in your CSP
Toolbar works locally but not in prodYou're on a non-localhost domain that isn't allowlistedAdd the prod domain in Settings → Domains
Toolbar appears but has no stylingCSP style-src has no 'unsafe-inline' — the widget's stylesheet is a <style> element in its shadow rootAllow it, or drop style-src for the widget's sake
Threads never load, no obvious errorCSP connect-src is missing the convex.site host or the wss://…convex.cloud oneAdd both (see §6)
Two widgets on the pageinit() was called more than once with different configsCall the returned destroy() first, or just call init() again — it self-replaces

8. Uninstalling / disabling

  • Remove the <script> tag, or stop calling init().
  • For React/Vue/Solid hosts, the cleanup function returned from your mount hook (the destroy returned by init()) tears it down on unmount.
  • To kill an active session manually: import { destroy } from '@pixelmatters/markup'; destroy().

Existing threads stay in the dashboard — uninstalling the widget doesn't delete data.


9. Screenshots & privacy

The widget captures the visible viewport when you drop a pin. Sensitive fields are blacked out before the image is produced — the host page is never permanently mutated, and nothing leaves the browser until the user explicitly attaches the screenshot and posts.

Auto-scrubbed by default: input[type="password"] and any <input> whose autocomplete attribute contains cc-number, cc-csc, cc-exp, cc-name, cc-type, current-password, new-password, or one-time-code.

To mask anything else, add data-markup-private to the element. To exempt a section from automatic detection, add data-markup-safe to its container. To remove an element from the screenshot entirely, add data-markup-skip.

To disable screenshots altogether:

ts
init({ apiUrl, apiKey, screenshots: { enabled: false } })

When a screenshot is attached in the composer, a chip shows how many fields were redacted. Clicking it expands the list of masked selectors so you can verify what was covered before posting.

End users can also switch capture off for themselves with Auto-capture screenshots in the toolbar's overflow menu. screenshots: { enabled: false } from the host still wins — that row renders disabled rather than offering a control that does nothing.

The same menu's Privacy & data panel spells this out for whoever is leaving the feedback, and says which of the three states is live: capturing, switched off by them, or turned off by your site.

If a capture doesn't work out, it degrades instead of vanishing: an image the browser won't hand over (typically a third-party avatar served without CORS headers) comes through blank while the rest of the page captures, an oversized capture is re-encoded — quality first, then scale — to fit the 2 MB cap, and if nothing works the composer reads Screenshot unavailable and the comment posts without one.

Product telemetry

The widget reports counts of its own interactions — comment started, comment submitted, screenshot captured, thread resolved — so we can tell which parts of it are used. Turn it off with:

ts
init({ apiUrl, apiKey, analytics: false })

or data-analytics="false" on the script tag.

Worth knowing before you decide, because it is not what "analytics" usually means:

  • No third-party script. Events post to your apiUrl on convex.site — the origin the widget already talks to — and our backend relays them. Nothing new for your CSP.
  • No identifier for your users. Events are keyed on the Markup project, not the person. The session id is generated per page load, kept in memory, and gone when the tab closes.
  • No cookie and no localStorage. Telemetry writes nothing to your page's storage.
  • No content and no IP. Comment text, page URLs, names, and email are never sent, and because the relay is server-side your visitors' IP addresses never reach our analytics provider.

Event names come from a fixed list the server re-checks, and properties are limited to counts and booleans.