loupe-widget
The drop-in. Renders pins, the composer and the feedback panel, and bundles the anchoring engine so one script tag is the whole install.
Installing
npm
npm install loupe-widget
// app/layout.tsx — once, at the root
import { createFeedback } from 'loupe-widget'
createFeedback({ publishableKey: 'pk_live_…' })Getting a key
No account needed. npx loupe-widget prints a pk_live_… and the backend provisions a personal-mode project the first time it sees it — no signup, no dashboard visit. Point the widget at your own backend with apiUrl and it works the same way.
Script tag
No build step. Works with WordPress, Rails, Django, Laravel or plain HTML.
<script src="https://cdn.loupe.dev/loupe.js"></script>
<script>
Loupe.createFeedback({ publishableKey: 'pk_live_…' })
</script>Tag manager
The route worth knowing about for a client site you don't deploy. Most such sites already run Google Tag Manager and someone on the client side already has access — a Custom HTML tag firing on all pages installs the widget with no repo access and no deploy. Webflow, Squarespace, Shopify and Framer all expose an equivalent custom-code field.
Options
| Option | What it does |
|---|---|
| publishableKey | Required. Safe in your bundle — it identifies the project and reads nothing. Generate your own with npx loupe-widget; the backend provisions a personal-mode project on first use. Writes made with it are checked against your verified domains; see below. |
| viewerToken | A string, or a function returning one. Your server mints it from the secret key. Without it, nobody sees the team's comments. |
| release | Commit SHA or tag. Ties each anchor to a deploy, which is what makes drift measurable. |
| label | Launcher text. Defaults to Feedback. |
| authorName | Attached to comments this browser files, when your app knows who the person is. |
| apiUrl | Point at a self-hosted API. Defaults to the hosted one. |
| mask | Selectors whose content is redacted before anything leaves the browser. Nothing is masked unless you name it — see below. |
| theme | Colours, radius and type. See below. |
| capture | What to gather with a comment. environment (viewport, screen, device class, platform) is on. console is off and must be turned on deliberately. |
| activation | See below. |
Theming
The widget runs on your site, in front of your users, so it should look like it belongs there. Twelve tokens, applied as custom properties inside the shadow root — nothing leaks out, and you never have to fight it with !important.
createFeedback({
publishableKey: 'pk_live_…',
theme: {
accent: '#7c3aed', // pins, the active tool, primary buttons
surface: '#ffffff', // panels and dialogs
ink: '#14181a', // body text
muted: '#5c6a69', // secondary text
line: '#e6e6ef', // borders and dividers
bar: '#14181a', // the launcher and the toolbar
barInk: '#edf1f0',
radius: '10px',
font: 'Inter, system-ui, sans-serif',
colorScheme: 'auto', // 'auto' | 'light' | 'dark'
dark: { surface: '#16161f', line: '#2a2a3a' },
},
})You do not set the text colour on your accent. It is derived from the accent's luminance, because white on a light brand colour is the mistake this option would otherwise invite — set accentInk if your brand insists on a specific pairing.
Setting accent alone applies it in both schemes. Setting a surface colour applies it only to light, since a white panel chosen for daylight should not still be white at midnight; put dark surfaces in dark.
Flagged and orphaned pins keep their own colours whatever you choose. They report the state of our resolver rather than anything about your product, and they need to stay readable and distinguishable from a confident pin.
Minting a viewer token
Your own auth decides who qualifies, so we never hold your user table. Fifteen minutes, refreshed silently.
// your server — the only place sk_ ever appears
const res = await fetch('https://api.loupe.dev/v1/viewer-tokens', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.LOUPE_SECRET_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ subject: user.id, name: user.name, role: 'member' }),
})
const { token } = await res.json()Activation
The failure that gets a widget deleted from a live site is an end user discovering the panel — or reading the team's internal notes about the page they are standing on. So the defaults differ by environment, and nothing renders at all before a credential resolves.
| Entry point | Default |
|---|---|
| Cmd/Ctrl+Shift+K | On everywhere. The only entry point with no visual footprint. |
| Launcher | On off production; opt in per project for production. |
| ?feedback=on | On. Persists for the tab, so a review link survives navigation. |
| loupe.open() | Always. Put it in your own admin nav, behind your own role check. |
createFeedback({
publishableKey: 'pk_live_…',
activation: {
chord: 'mod+shift+k', // or false
bubble: false, // 'auto' | true | false
bubblePosition: 'bottom-right',
urlParam: 'feedback', // or false
},
})Leaving feedback
Pressing Feedback opens a toolbar and arms the comment tool. Leaving feedback is a mode, not a single action: the tool stays armed across picks, and sending or cancelling the dialog returns to it rather than switching it off. Escape unwinds one layer at a time — the dialog, then the tool, then the toolbar.
Click an element to pin a point. Drag across an area and the anchor lands on the deepest element that fully contains it — the image for part of a hero, the figure for an image plus its caption, the grid for a span of cards. The selection is stored as fractions of that element, so it survives the same reflow the element does.
A click with nothing under it but <html> or <body> — most of a centred layout — files a page-level comment: a thread with no anchor, which lists everywhere and draws no pin. An anchor on the root element is a fraction of the whole document; it resolves perfectly, scores as confident, and puts a pin in the gutter. Screenshot does the same for the whole page, captured with getDisplayMedia — one permission prompt, and what the person actually saw.
The dialog offers a type and a priority, both optional and blank by default. A required picker is a tax on the person doing you the favour of reporting something, and produces a backlog marked with whatever was easiest to click.
What is gathered with a comment
capture.environment is on: viewport, screen, device class, pixel ratio, platform, language and user agent. Device class comes from touch and width rather than the user-agent string — iPadOS has reported itself as a Mac for years, so a UA answer is wrong for the device people most often report from.
capture.console is off and has to be turned on per install. It keeps the last 50 lines. It is the one thing here that can carry your data — apps log tokens, addresses and whole API responses — and this runs on your site, so it is not something to switch on by accident.
It captures from the moment the widget finishes booting, not from the moment the page started — boot waits on the config request and a device registration first. If your interesting errors happen during load, this will not have seen them.
Reading feedback
The launcher shows how many open threads are on this page. Opening it arms the comment tool; the list is behind the toolbar's Feedback button, and clicking a pin opens that thread directly. Either way the sheet opens beside the pin it belongs to — right, left, below or above, whichever side fits whole.
You can edit and delete your own comments. Actions end where their intent points: resolving is finishing, so the sheet closes, and deleting your last comment removes the thread and closes it too. Reopening stays on the thread, because you have just said this is not done and the next thing you will do is say why. Resolved threads sink under a heading rather than vanishing, and their pins mute rather than disappearing — a thread that disappears on resolve cannot be reopened by whoever needs it back. Hovering a pin shows the author and the comment without opening anything.
A link of the form ?lp_thread=<id> opens that thread on load and scrolls to its pin — which is what the Open on site link in your inbox uses.
Masking
On a logged-in page, the two things that carry content upward are the anchor and the screenshot. Anchoring stores up to 120 characters of an element's own text, its aria-label and its alt — that is how a pin survives a redeploy, and on an account page it is somebody's name. mask takes selectors, and their content is removed before either leaves the browser.
createFeedback({
publishableKey: 'pk_live_…',
mask: ['.customer-name', '[data-private]', '#invoice-total'],
})A child of a masked element is masked. A neighbour is not: siblings are captured separately, so a masked element does not take the innocent element beside it with it. Dragging a region that touches anything masked blanks all of the text, because a region anchors to whichever element contains it rather than the one under the pointer.
Structure is kept on purpose. The selector, the xpath, the tag path, the id and the stable classes all survive. Blanking them would hide nobody — the element stays just as visible on the page — and would only stop the pin from ever being found again. A masked element anchors on structure alone, so expect it to drift sooner than an unmasked one.
An invalid selector throws and the widget does not start. A mask that quietly matches nothing behaves exactly like a correct one until someone reads the stored data, which is the worst place for a mistake to surface. It is checked at boot, before any network call, so the failure lands in development where the typo is.
Screenshots are refused rather than mis-redacted. The browser's share picker does not have to give us this tab — the person can hand over another window or a whole monitor — and the rectangles being painted are in this page's coordinates. Rather than produce a picture with black boxes in the wrong places, which looks redacted and isn't, the capture is abandoned and the toolbar asks for this tab instead. Nothing is masked by default, inputs included: a screenshot shows whatever was on screen.
Sharing a recording
The Record button in the toolbar records a video of the page — the masked canvas, not the raw pixels — and uploads it when the person stops sharing. It is the samegetDisplayMedia prompt as Screenshot, recorded at 15 fps into a webm with the masked areas painted out frame by frame. The dashboard's Recordings pages list every capture: a row exists from the moment they say Share, so a recording abandoned mid-capture shows up rather than vanishing, and the playback appears the moment the upload lands.
It refuses to lie about privacy exactly like the screenshot does. The masking rectangles are painted in this page's coordinates, so if the share picker hands over another window or a whole monitor, the capture is abandoned and the toolbar asks for this tab instead. And the browser has to produce a webm — the storage only accepts that container — so on Safari, which records mp4, the widget says recording is unsupported in this browser rather than happily storing nothing.
Recordings are capped at 60 MB on upload. Nothing is recorded by default: the button is a person pressing a button, not a passive capture.
Getting other people in
Three ways, in the order the widget prefers them. A viewer token your server mints from the secret key — your own auth decided this person qualifies, so it outranks everything else. An invite link, which is the one to reach for when the person is a client rather than a colleague. Or a join code typed into the widget.
An invite link is made on the project page and looks like https://your-site.com/?lp_invite=inv_…. Whoever opens it joins as a member on that browser: they see the team's feedback, the team sees theirs, and there is nothing for them to install or sign up for. The widget redeems it once and removes the parameter from the address bar, so it is not shared by copy-paste or handed to the next site in a Referer header.
This is the only route that works on a live production page. Nothing renders there without a credential — that is the rule that keeps your real users from finding the panel — so there is nowhere to type a code. A code is for someone already seeing the widget in personal mode who wants to see the whole team's feedback instead.
Revoking a link stops anyone new from using it and deliberately leaves everyone already in. Those are removed one device at a time from the project page, which is the point of binding a membership to a device rather than handing out a shared password.
Domains
A publishable key sits in the source of every page it ships on, so anyone who loads your site can read it. What that gets them is bounded by which domains you have verified: once a project has at least one, a write carrying a publishable key has to arrive from one of them, and anything else is refused with a 403. A browser sets the Origin header itself and page script cannot touch it, so a page cannot claim to be somewhere it isn't.
Verify a domain from the project page: publish the TXT record it shows you at _loupe.<host>, then press Verify. A wildcard is verified against its apex, so *.acme.com needs the record on acme.com. Until something is verified there is nothing to check against and every origin is accepted — a new project works before its DNS has propagated, which is the point.
localhost is always allowed, so a local build can point at a real project without putting 127.0.0.1 on a production allowlist. Requests carrying no Origin at all are allowed too: only a browser is unable to lie about that header, so refusing the empty case would block server-side and native callers while stopping nobody. This is a check on where a browser is, not an authentication step — a secret key or a viewer token skips it, having been minted by your own server and never printed on a page.
When a pin loses its element
Anchoring is best-effort by construction. An element can be deleted outright, and no amount of scoring finds something that is not there — so a thread whose element has gone draws no pin, because a pin in the gutter would be a claim nobody made.
It is not lost, though. It appears in the feedback list under Lost their place, with its pin number replaced by a dash and a button that arms a picker: click the element it means now and the pin moves there. Only the person who opened the thread can do that, plus your server and your dashboard — a reply is a comment on somebody else's report, and moving what that report points at rewrites their meaning rather than adding to it.
Single-page apps
Route changes are detected by patching history.pushState and replaceState and listening for popstate; the widget re-fetches when the path actually changes. Pins are matched by route pattern, so /blog/[slug] shows the same template feedback on every post. DOM changes reposition pins on a debounce; they do not trigger a fetch.
destroy() restores the history methods it patched, so mounting and unmounting repeatedly leaves nothing behind.
One catch: if the page unmounts while createFeedback is still resolving, the widget keeps booting anyway — call destroy() on the handle once it resolves, or it stays mounted and follows the visitor to the pages they reach next. React StrictMode double-mounts effects in development and is exactly how to hit this.
API
| Member | Purpose |
|---|---|
| createFeedback(opts) | Boots the widget. Resolves to the handle below. |
| loupe.open() | Opens the toolbar and arms the comment tool. |
| loupe.close() | Closes the toolbar, panel, composer and any selection in progress. |
| loupe.refresh() | Re-fetches this route's threads and repaints. |
| loupe.state() | { credential, config, production, deviceId }. Useful when it renders nothing and you want to know why. |
| loupe.destroy() | Removes everything and unpatches history. |
Size and CSP
The eager path is 15680 bytes gzipped across five files, against a 15800-byte budget — resolver, pin layer, token handshake, theme tokens, device and membership records. The composer, panel, toolbar, selection gesture, screenshot capture, session recording, environment snapshot, console tap, masking, drift reporting and joining are all dynamic imports, so they cost nothing until someone acts. Five files rather than one because bundlers emit shared chunks that the entry imports statically; counting only the entry is how this number was under-reported for a while. npm run size fails the build if the eager path goes over, and the budget does not move to accommodate a feature.
Under a strict script-src policy, allow the bundle's origin or self-host it; SRI hashes are published with each release. This is the quiet reason widget pilots stall, so it is worth checking before you roll out.