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 floating button in the corner of your app. Anyone who opens the page can click it, drop a pin anywhere, and leave a threaded comment (with optional annotated screenshot). 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 — sign in with Google |
| A Project | Dashboard → + New project |
| An API key | Project → Settings → API Keys → New key (the raw key is shown once — copy it) |
| Your API URL | Project → Settings → Install — looks like https://<your-deployment>.convex.site |
| (Production) Your host domain | Project → 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 floating action button 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'
}) // returns a destroy() function — call it on unmount / logout / route teardown
```
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.14.0'
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.14.0"
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 button 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://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.14.0'
// or
// import { init } from 'https://esm.run/@pixelmatters/markup@1.14.0'
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'
fab: 'default', // optional: 'default' | 'icon-only'
})
</script>Pin the version. A bare
@pixelmatters/markupURL resolves to whatever'slateston npm — a future major release will break your page silently. Always pin (@pixelmatters/markup@1.14.0).
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:
<script
type="module"
src="https://esm.sh/@pixelmatters/markup@1.14.0"
data-markup-widget="true"
data-api-url="https://your-deployment.convex.site"
data-api-key="markup_..."
data-position="bottom-right"
data-theme="auto"
data-fab="default"
></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)
# 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'
theme: 'auto', // optional: 'auto' | 'light' | 'dark'
fab: 'default', // optional: 'default' | 'icon-only'
})
// 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
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'
fab: 'default', // optional: 'default' | 'icon-only'
})
}, [])
return <>{/* your app */}</>
}Vue 3
<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'
fab: 'default', // optional: 'default' | 'icon-only'
})
})
onBeforeUnmount(() => stop?.())
</script>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'
fab: 'default', // optional: 'default' | 'icon-only'
})
onCleanup(stop)
})
return <>{/* your app */}</>
}Tip — keep keys out of the repo. Store
apiUrlandapiKeyin 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
| 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 FAB. Dragging snaps to whichever third of the viewport the pointer lands in (left / center / right). |
theme |
'light' | 'dark' | 'auto' |
'auto' |
'auto' follows the host's prefers-color-scheme |
fab |
'default' | 'icon-only' |
'default' |
Floating button variant. 'icon-only' drops the "Markup" label for a circular icon button |
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.
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 floating button 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 topopstate, so threads refresh on route changes. - Identity: anonymous by default; signed-in team members get a verified badge.
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) |
| Floating button 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 |
| Button works locally but not in prod | You're on a non-localhost domain that isn't allowlisted | Add the prod domain in Settings → Domains |
| Two widgets on the page | init() was called more than once with different configs |
Call the returned destroy() first, or just call init() again — it self-replaces |
8. Uninstalling / disabling
- Remove the
<script>tag, or stop callinginit(). - For React/Vue/Solid hosts, the cleanup function returned from your mount hook (the
destroyreturned byinit()) 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, 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.