Install and use the Markup widget
A step-by-step tutorial for getting the @pixelmatters/markup widget running in any web app. Written to be read 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 sees the pins; who can leave one is a project setting. New projects take comments from signed-in project members only, so every author can be
@-mentioned and notified; a project can instead be opened to anyone on the page, who then comments under a name they type. Threads stream into the Markup dashboard in real time.
1. What you need before you start
| You need | Where to get it |
|---|---|
| A Markup account | Markup dashboard, signing in with Google or an emailed link. Accounts are invite-only |
| A Project | Dashboard → + New project |
| An API key | Project → Settings → API Keys → New key. The raw key is shown once, so copy it |
| Your API URL | Project → Settings → Install. It looks like https://<your-deployment>.convex.site |
| (Production) Your host domain | Project → Settings → Domains, where you add app.example.com, *.staging.example.com, and so on. 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, to 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.
For React or Vue, use `useMarkup` from `@pixelmatters/markup/react` or
`@pixelmatters/markup/vue` once near the app root. For Solid and others, call
`init()` from your framework's mount hook (`onMount`, …) and call the returned
`destroy` on cleanup. Snippets for all three 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.31.5'
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.31.5"
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>:
<script type="module">
// Pin the exact version; esm.sh resolves it from npm
import { init } from 'https://esm.sh/@pixelmatters/markup@1.31.5'
// or
// import { init } from 'https://esm.run/@pixelmatters/markup@1.31.5'
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/markupURL resolves to whatever'slateston npm, so a future major release will break your page with no warning. Always pin (@pixelmatters/markup@1.31.5).
When inline JS isn't allowed
Some CMS and page-builder editors only let you paste a <script src=…> tag, with no inline code. For those, use the auto-init form, where config travels via data-* attributes:
<script
type="module"
src="https://esm.sh/@pixelmatters/markup@1.31.5"
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, since it's how the bootstrap finds its own <script> tag (document.currentScript is null for type="module").
Path B: vanilla JS or TypeScript, with any bundler
# pnpm
pnpm add @pixelmatters/markup
# yarn
yarn add @pixelmatters/markup
# npm
npm install @pixelmatters/markupimport { 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
React and Vue have a useMarkup of their own. Solid calls init(), which is plain JS, from its mount hook once at the root, and calls the returned destroy on unmount.
React
React 18 or later. Render the component once near your app root:
'use client' // Next.js App Router only
import { useMarkup } from '@pixelmatters/markup/react'
export function Markup() {
useMarkup({
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 null
}An inline options object is fine; the widget remounts only when a value
changes. StrictMode leaves one widget, and enabled: false unmounts it.
Vue 3
Vue 3.3 or later. Call it once from your root component:
<script setup lang="ts">
import { useMarkup } from '@pixelmatters/markup/vue'
useMarkup({
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'
})
</script>The composable takes the same options as init(), plus enabled, as a plain
object, a ref or a getter (() => ({ …, theme: theme.value })), and remounts
the widget only when a value changes. Two components with the same options
share one widget until both unmount, and nothing mounts during server
rendering.
SolidJS
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
apiUrlandapiKeyin environment variables (VITE_MARKUP_API_URL,VITE_MARKUP_API_KEY, and so on). The widget key is a public key, bound to your domain allowlist, but rotating it through env vars is still cleaner than committing it.
4. Configuration reference
| Option | Type | Default | Description |
|---|---|---|---|
apiUrl | string | required | Your Convex deployment site URL (https://*.convex.site) |
apiKey | string | required | Project 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() |
screenshots | ScreenshotsConfig | capture enabled | Capture toggle and PII-scrub options; see §9 |
dashboardUrl | string | none | Adds an Account → link to the identity menu for signed-in users. Mostly for self-hosters, whose dashboard origin the widget can't infer |
routeParams | string[] | [] | Query params that name a view rather than filter one, so each gets its own pins. See §Routing |
init(config) is idempotent. Calling it twice with the same config is a no-op, and calling it with new values tears down the old instance first. It returns a function that tears down that instance, and does nothing once a later init() has replaced it. The exported destroy() tears down whichever instance is mounted.
The fab option from before 1.15.0 is deprecated and ignored, because 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
| Control | What it does |
|---|---|
| Comment | Arms placement. The next click on the page drops a pin, while cmd/ctrl + click hides the whole widget |
| Inbox | Mention notifications for this project, with an unread badge. Signed-in users only |
| Pins (eye) | Hides or shows every pin without hiding the toolbar |
| Identity | Avatar button. Sign in, or once signed in, name, email, Account →, and Sign out. Anonymous visitors also get "Forget me on this site". On a members-only project this is where a visitor is sent to comment |
Overflow (☰) | Appearance (Light / Dark / Auto), Position (left / center / right), an Auto-capture screenshots toggle, an Only this URL's pins toggle, a Show resolved threads toggle, Privacy & data, Keyboard shortcuts, Hide for this session, and the widget version |
Privacy & data and Keyboard shortcuts open inside the menu itself, replacing the rows until you press Back (or esc). 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. Anonymous visitors also get "Forget me on this site" there. 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, enter sends a reply (shift + enter for a new line), cmd/ctrl + . toggles the whole widget, and esc backs out of whatever is open. The Keyboard shortcuts panel has the full list, reachable from the menu only, so the widget isn't claiming a plain key your app may already use.
Routing
A thread is filed under a route, and pins are drawn for the route you are
standing on. A route is host + pathname, plus a hash-router path when the
fragment is one — so app.acme.com/orders and staging.acme.com/orders never
share pins, and neither ?page=2 nor #section changes which route you are
on. A filter over a page is not a different page, and keying on one would
split a conversation across every combination a reader happens to have.
Some apps do route a view by param, though: a multi-step form at one path with
?step=shipping, ?step=payment. Those are different pages sharing a path,
and by default they share one set of pins. List the params that name a view:
init({ apiUrl: '…', apiKey: '…', routeParams: ['step'] })/checkout?step=shipping and /checkout?step=payment now have their own pins.
Params you don't list are still ignored, so ?step=payment&page=2 and
?step=payment&page=3 stay one page. Order doesn't matter; the key sorts them.
This reads the real query string only. On a hash router the query sits inside
the fragment (#/orders?tab=2), where there is no query string to read, so
listing tab does nothing — put the distinguishing part in the router path
(#/orders/tab/2) instead, which the route already carries. Tell us if that's
your setup.
Changing routeParams re-keys new threads, so pins already stored under the
old route stop appearing until we run the matching backfill. Threads store
their full URL, so nothing is lost and the backfill can be re-run whenever the
list changes — but there is a window. Tell us when you adopt or change it.
Leaving it empty is right for most apps. Where views share a route, each pin's tooltip names the URL it was left on, and the overflow menu's Only this URL's pins narrows the page to one view without changing anything stored.
Anchors and source paths
Two optional attributes, both read from your page as it is:
data-markup-anchor="pricing"names an element for pin selectors, ahead of ids and test attributes. Treat it like an id: a pin on an element whose name is unique on the page stays attached when its text changes, while a name several elements share is only a hint beside their position. In a list that can be reordered, name each item (data-markup-anchor="todo-42") so a pin follows its item instead of its position. Unnamed, a moved item is still found when its text is unique in the list, but a pin on one of several identical buttons stays with whichever row now holds its old position.data-markup-source="src/components/Pricing.tsx"is stored with every thread pinned inside that element. The dashboard shows it, and the MCP server hands it to agents asanchorSource. Values over 512 characters are ignored.
5. Try it: a 60-second smoke test
- Drop the snippet from Path A into a blank
index.html. - Open the file with a local server (e.g.
npx serve .). Production deployments don't auto-allowlocalhost; addlocalhostto Settings → Domains for a quick test, or pointapiUrlat a self-hosted dev deployment withMARKUP_ALLOW_LOCALHOST=1. - Click the comment button on the toolbar in the bottom-right.
- Click anywhere on the page → write a comment → submit.
- 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
MutationObserverondocument.bodyre-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/replaceStateand listens topopstateandhashchange, so threads refresh on route changes. Hash routers work too:#/settingsis part of the route a thread is filed under, while a plain#sectionanchor is not. A query string is not either, unless you opt one in withrouteParams— see §Routing. - Two origins: comments, identity, screenshots, and error reports go to
https://<deployment>.convex.site; live thread updates arrive over a WebSocket towss://<deployment>.convex.cloud, which the widget derives fromapiUrl. A CSP needs both underconnect-src. - Identity: anonymous by default on projects set to "anyone"; members-only projects ask visitors to sign in before their first comment, and refuse anonymous writes server-side. 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
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized in the network tab | Wrong / revoked key | Mint a fresh key in Settings → API Keys |
403 origin not allowed | Host domain isn't in the project's allowlist | Settings → Domains → add the domain (or *.staging.example.com) |
| Toolbar doesn't appear | Auto-init <script> missing data-markup-widget="true", inline init() not called, or CSP blocks esm.sh | Add the attribute, call init(), or allow the script origin in your CSP |
| Toolbar works locally but not in prod | You're on a non-localhost domain that isn't allowlisted | Add the prod domain in Settings → Domains |
| Toolbar appears but has no styling | CSP style-src has no 'unsafe-inline', and the widget's stylesheet is a <style> element in its shadow root | Allow it, or drop style-src for the widget's sake |
| Threads never load, no obvious error | CSP connect-src is missing the convex.site host or the wss://…convex.cloud one | Add both (see §6) |
| Two widgets on the page | init() was called more than once with different configs | Call the returned destroy() first, or just call init() again, since it self-replaces |
8. Uninstalling / disabling
- Remove the
<script>tag, or stop callinginit(). - With
useMarkup(React or Vue), unmounting the component tears it down, and so doesenabled: false. Elsewhere, thedestroyreturned byinit()does. - 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:
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, and 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 by dropping quality first and then scale until it lands near 400 KB, with the server's 2 MB cap as the hard limit, and if nothing works the composer reads Screenshot unavailable and the comment posts without one.
An attached screenshot travels with a 640px-wide thumbnail of the same redacted image. Threads show the thumbnail inline and load the full screenshot only when someone opens it.
Product telemetry
The widget reports counts of its own interactions, such as comment started, comment submitted, screenshot captured and thread resolved, so we can tell which parts of it are used. Turn it off with:
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
apiUrlonconvex.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.