Markup

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/markup URL resolves to whatever's latest on 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/markup
import { 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 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

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

  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 floating button 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

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

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.