Docs · raw markdown
The widget
One script tag. The loader is 3,698 bytes gzipped, inside a hard budget of 3,712, with no dependencies and draws nothing into the host's DOM; the mark and the bar live in a closed shadow root. The rest (about 56 KB gzipped) loads once: two seconds after the loader runs, at once on a page where a See it in the app walk is in progress, or sooner when someone hovers or presses the mark or presses the keyboard shortcut, whichever comes first.
The tag
<script src="https://shutterbug.dev/shutterbug/loader.js"
data-shutterbug-project-key="pk_…"
data-shutterbug-endpoint="https://shutterbug.dev"
data-shutterbug-token-url="/api/shutterbug/token"></script>| Attribute | Values | Default | What it does |
|---|---|---|---|
data-shutterbug-project-key | pk_… | required | The project's key from the console. Public. |
data-shutterbug-endpoint | URL | required | Where reports go. https://shutterbug.dev. |
data-shutterbug-token-url | same-origin path | none | The host's token route. Required on any origin other than shutterbug.dev. |
data-shutterbug-release | text | from <meta name="shutterbug-release">, else unknown | The build id every report carries. Append dirty for an uncommitted build. |
data-shutterbug-launcher | mark, none | mark | none hides the mark; open the widget from your own control with window.shutterbug.open(). |
data-shutterbug-position | bottom-right, bottom-left | bottom-right | Which corner the mark sits in. |
data-shutterbug-offset | x,y in px | 0,0 | Nudge the mark away from the corner. |
data-shutterbug-theme | light, dark, auto | auto | Force a theme. auto follows your page: the color-scheme on its root when that is light or dark, the system's setting when it is light dark, otherwise how dark the first opaque background from body up is, and light when nothing paints one, since the page under it is then white. It reads your page again when the system's setting changes or your root or body changes its attributes, as a theme switch does, and writes nothing to it. The mark the loader draws before the widget loads is light unless you pass dark. |
data-shutterbug-hotkey | a combination, or false | Alt+S | The keyboard shortcut. false disables it. Ignored while typing in a field. |
data-shutterbug-allow-origins | comma-separated origins | the page's own origin | Which requests are recorded in the network tail. Requests to shutterbug.dev are never recorded. |
data-shutterbug-mask-selectors | comma-separated CSS selectors | none | Elements masked in the screenshot, on top of the fields masked always. |
data-shutterbug-shared-reporter | present, or true (false turns it off) | off | Your token route sends one reporter id for everyone, so Your reports is titled Reports on this app and lists everyone's reports. See Your reports below. |
data-shutterbug-mask | on any element | none | Mask this element in the screenshot. Password fields, and fields whose autocomplete is a card, code or password token, are masked always. |
The API
The loader sets window.shutterbug:
open()opens the bar (loads the widget on first use). If a draft is kept, it reopens that draft as it was left.close()closes the bar, asEscdoes. A draft holding something the person made (a note, a picked element, the screenshot left out) is kept behind the mark until they send it or discard it; see Sending a report below.destroy()removes the mark, the listeners and the buffers, and ends any draft: one waiting for the connection is not sent, and one already sending may still arrive, with no receipt.
The host element and your shortcuts
The widget lives in one div with the attribute data-shutterbug-host, appended to body: the loader's mark first, then the widget itself once it loads. Its shadow root is closed, so your code cannot see inside it. The attribute is a stable contract: it stays on the element that holds the widget, whatever else about the widget changes.
While the bar is open, keys typed into it stop at its shadow root, after the widget has handled them. A shortcut handler on window or document in the bubble phase (the default for addEventListener, and how most hotkey libraries listen) never sees them, so typing a note cannot fire your shortcuts.
A handler that listens in the capture phase still sees those keys, before the widget does, and its event.target is the host div, not the note. Such a handler should ignore events from inside the widget:
window.addEventListener("keydown", (event) => {
const target = event.target;
if (target instanceof Element &&
target.closest("[data-shutterbug-host]")) return;
// your shortcuts
}, true);The same check covers the mark. When the bar closes, the mark takes the focus back, and a key pressed on it reaches your listeners in either phase, with the host div as the target, whether or not a draft is kept behind it; so does a key pressed on the loader's mark before the widget has loaded. Keys typed in the receipt that replaces the bar after a send stop there too; the widget's own shortcut still works there and starts the next report.
Content Security Policy
The widget runs under a strict policy. Its styles need no nonce: they are constructed stylesheets adopted into its own shadow roots, so style-src 'self' is enough, and nothing it draws carries a style attribute. Allow three things:
script-src: the origin you loadloader.jsfrom. A policy with a nonce and'strict-dynamic'ignores the origins listed inscript-src, so there the loader's tag needs the page's nonce instead (nonce="…"on the tag, or thenonceprop onnext/script, shown in Install). The loader loads the rest of the widget itself, which'strict-dynamic'allows.connect-src: the endpoint origin, where reports go, and the origins of the web fonts and images the page uses ('self'for its own, served with CORS for the rest), or the screenshot draws without them. For fonts from a font service that means two origins: the stylesheet's (fonts.googleapis.com), because the screenshot reads the sheet's text to find the font files, and the files' (fonts.gstatic.com). A fetch the policy refuses also files a violation report, so a missing origin shows up in your CSP reports on every capture.img-src:data:. The screenshot is drawn from adata:image; without it the widget says it couldn't capture the screen and still sends the note.
Trusted Types (require-trusted-types-for 'script') is not supported: the loader and the widget write their icons with innerHTML, which such a policy refuses, so the mark never appears.
The widget needs Chrome 100, Firefox 101 or Safari 16.4 or later. It is built on constructed stylesheets; a browser without them gets no widget, because the loader checks and stays off.
Sending a report
Pressing the mark, or the shortcut, opens the bar and takes a screenshot of the page as it is. Nothing is drawn into your page.
The screenshot fills the top of the bar at a size that can be read, and opens at full size on a click. Point at what's wrong lets the person pick an element: the bar steps aside for a small bar in its place (Click what's wrong, with Cancel), and once they click, the screenshot is taken again around that element and the outline moves from the page into it. With nothing picked, one press takes the screenshot again.
Sent with it is one sentence under the note that says what goes with the report, in plain words and only what is there: the page, the screenshot, what they picked, their recent steps (a press on a control or a route change) and technical details from their browser (the requests, console lines, browser and viewport). Nothing in it can be removed: requests, console lines and steps always go, redacted as below. The screenshot is the one thing a person can leave out, with Remove on its frame; Put it back restores it until they send, and Take one takes a fresh one.
A kept draft. Closing the bar keeps what the person made: the note, the picked element, and the screenshot or that they left it out. The mark then reads Finish your report, and pressing it brings the draft back as it was, without a new screenshot. Discard ends a draft, after asking. A draft nobody touched ends when the bar closes. A draft lasts until the page loads again, except a note kept across a sign-in (below). Sent from another page or later, it still carries the page, viewport and element its screenshot was taken with, and the bar says Taken on that page.
Offline. A send that fails because the browser is offline is kept, and the bar says it sends itself when the connection is back; meanwhile Send reads Send now, and behind the closed bar the mark reads Sends when you're back. The report is sent again without a press each time the connection returns; a failure of any other kind waits for the person to press Send. It carries the same draft id, so it cannot be filed twice.
A note across a sign-in. When a send is refused because the person's session expired, the bar says the note is kept, and the note's words (only the words: no screenshot, no picked element) are kept in the tab's session storage (below) while they sign in again. Any page load in that tab within 30 minutes brings them back as a kept draft with a fresh screenshot, so your sign-in page can load the widget without spending them. They go when that draft is sent or discarded, or after 30 minutes.
The receipt. After a send the bar settles into a receipt (Logged as #041.) with See it, which opens the report in Your reports. It closes itself after eight seconds, or waits while the pointer or the keyboard focus is on it.
Your reports
A reporter can open the mark and see what they have raised, answer what the team asked, add to a report that is still open, read what the team did about the rest, open a fix in your app, and send it back if it isn't right, without leaving your app. Nobody is asked to confirm anything.
The dot. When a report needs the reporter (the team asked a question) and has moved since they last opened Your reports, the mark carries a small dot. It has no number, and a fix being done does not light it. Hovering the mark shows a second line, such as 1 needs you · 4 done since you looked, which opens Your reports; on a touch screen the bar's header has a Your reports link.
The sheet. It opens from the mark's side of the page, 736 px wide, and leaves your app in view; on a phone it comes up from the bottom. Reports are grouped as Needs you (the team asked a question), Done, With the team, and Closed, which is collapsed to a count. A row of buttons at the top jumps to each group. Done is news: the newest first, each row with the line the team wrote saying what changed, and an Earlier divider between what was done since the reporter last opened the sheet and the rest. Done and Closed together show at most 200 rows, the oldest Closed first; Needs you and With the team are never cut. Any report opens inside the sheet, with the screenshot and the picked element outlined, the reporter's note and the lines the team wrote for them, and previous and next through its group. The team's internal notes and the trail never appear.
Answering, adding, sending back. Under Needs you each report has an answer box, up to 500 characters. Under With the team a report has Add something, which opens a box for anything the reporter wants to add to it, up to 500 characters; the team sees it as replied. Under Done a report has See it in the app and, under it, Not fixed? Send it back, which opens a box labelled What's still wrong?. It needs words, up to 500 characters, and sends the report back to the team as to do. A report that is done is done: no reply reopens it by accident, and a closed report takes nothing.
See it in the app. See it in the app, on a done report, walks the reporter through the Done reports in your app, page by page. A card at the corner shows the report's screenshot from when it was filed, beside your page as it is now: each step goes to the report's page, scrolls to the element they picked and outlines it, and shows the team's line. There are no verdicts to give; Not fixed? Send it back is there if the fix isn't right, and it sends words. Previous and next move through the reports, nothing advances on its own, and Your reports on the card ends the walk and opens the sheet. A report the team reopened or archived since the list loaded is skipped, and the next step says so (#022 changed since the list loaded.). The mark and the sheet step aside while the card shows. The card keeps clear of the element it asks about: on a wide screen it takes the side that covers it least, with a smaller screenshot when neither side clears it, and on a phone it moves to the top edge when the bottom would cover it; when it cannot clear it, it says the element is under the card, and to scroll your page only when scrolling can bring the element into view.
Your page's scroll. The walk scrolls your page, and any scrolling container the element sits in, to bring each element into view, at once rather than smoothly whatever your scroll-behavior says, so it can tell what it moved. When the walk ends, or the widget is destroyed, it puts back every offset it moved, the window's included, as they were before its first scroll; a pane the reporter scrolled themselves during the walk is left where they put it.
Who sees what
Your reports lists the reports filed under the reporter id your token route sends. Give every person their own id and each sees their own reports. If your app sends one shared id for everyone, add data-shutterbug-shared-reporter to the tag (or sharedReporter on <Shutterbug>) and it is titled Reports on this app and lists everyone's. Without the attribute a shared id still works; the title says Your reports, which is wrong only in the title.
What it costs your page
The widget wakes two seconds after the loader runs and asks for the reporter's counts once per full page load, and again when the sheet opens and when it closes: one call to your token route and one small read from the endpoint. Navigations inside a single-page app cost nothing. Close the page before two seconds, or call destroy(), and the widget never loads. While a See it in the app walk is stored for the tab (see below) it wakes at once instead, so the card is back as soon as the next page loads. Screenshots in Your reports are resized on the server and framed on the element that was picked: a region around it, or the whole page when nothing was picked or the pick is large. They are fetched four at a time as they scroll into view. Opening one report, or a screenshot at full size, loads that screenshot as it was stored, up to 1,600 px on its longest edge.
What it keeps in your origin's storage
localStorage["shutterbug:seen:<key>"]: the last activity Your reports showed among the reports that need the reporter, so the dot clears.<key>is the project key. It is per browser on purpose.localStorage["shutterbug:looked:<key>"]: the server's time from the last time the sheet opened, so the Earlier divider and the hover line's done since you looked count from it. It is the server's clock, not the browser's, and a browser with no key yet counts every done report once.sessionStorage["shutterbug:walk:<key>"]: a See it in the app walk in progress, so it can carry on after a page change. It holds the reports' ids and which step is shown, which reports this walk sent back, any words typed into Not fixed? Send it back and not sent yet, and the step being opened as the card shows it: its number, route, date, the first lines of the note (at most 300 characters) and the team's line, and, when it was already loaded, its screenshot as adata:image of tens of kilobytes with the outline of what was picked. It also holds where the pointer was when the reporter pressed, so the button under it still looks hovered on the next page, which side the card was on, and whether the next step owes the reporter a line (a report that changed since the list loaded). It is written as the walk moves, never on each keystroke, and when the page hides, so words typed into the send-back box survive a reload. It is cleared when the walk reaches its end.sessionStorage["shutterbug:note:<key>"]: a note's words, kept when a send is refused because the session expired, with the time they were kept, so the next load in the tab can restore them (see A note across a sign-in). It is removed when that draft is sent or discarded, or once it is more than 30 minutes old;destroy()leaves it.
All four are read and written inside try. Without storage the widget still works: the dot shows whenever something needs the reporter, nothing is marked as new in Done and the hover line counts every done report, a walk offers each page as a link instead of going to it, and an expired session keeps the note only until the page reloads.
See it in the app and your routes
The walk follows only a path on your own origin, and goes to each report's page at most once. A page behind a redirect is offered to the reporter as a link instead of followed. A route the report recorded with a value masked out of its query (a parameter whose name looks like a token, a key or a code) is never followed: when the reporter is already on its path the walk treats it as that page, and otherwise it offers the path, without the query, as a link. A route kept in the URL's hash is recorded without the hash, so the walk reads it as the page the reporter is on when its path and query match that page's, and otherwise goes to its path. Anything that is not a path on your origin is never followed or linked; the card says the report's page can't be opened from here, and the reporter can still look at it where they are or move past it. While the reporter has a draft they put something into (words, a picked element, something left out of what is sent, or a send waiting for the connection), in the open bar or kept behind the mark, the walk never leaves the page on its own: the card says that opening the page drops the unsent report and offers Finish your report, which ends the walk and reopens the draft. If the element is not on the page any more the card says so, and when its text has changed it says It may have changed.
Your reports and the CSP
No new entries beyond Content Security Policy above. The screenshot is a data: image, which the section above already asks for, and the reads go to the endpoint origin your connect-src already lists. An answer or an addition the team's last move refused stays in the report as You · not sent, with a Copy that writes the words to the clipboard on the reporter's press; where the page cannot write to it (not a secure context, or a Permissions-Policy without clipboard-write around an embedded app) the button stays Copy and the words stay on screen to select.
What a report carries
- The note the person wrote, and the element they pointed at: its selector, its text, a short HTML snippet, and where it sat on the page.
- The route and the build. The route, the viewport and the element are the ones the screenshot was taken with, even when a kept draft is sent from another page.
- Every request on the allowed origins: method, URL, status, duration. For one that fails (status 400 or above), the first 2 KB of the response body, so the error is on the report; it is read from a copy after your code has the response, so a slow or endless error body never holds your code up. Never headers, never request bodies. Next.js router prefetches (the
?_rsc=fetches behind every link in view) are left out unless they fail. A run of identical requests in a row (same method, URL and status), such as a Server Action a page sends every few seconds, is one row with a count, the latest duration and the slowest, so it cannot push everything else out of the last 50 rows. - The last 50 console lines, of every level (log, info, warnings and errors), each with its level.
- The presses on links, buttons, fields and other controls, and the route changes, since the last report was sent, as selectors (the last 20).
- The viewport, device pixel ratio and browser.
- A screenshot of the page, or with an element picked, of the area around it (at least 640 × 360, drawn at twice the size), with the element outlined, masked before pixels exist. A video appears as its poster, or as an empty box of its size; the screenshot never loads the video again. Canvases are drawn until four million canvas pixels are in the screenshot; a canvas beyond that is drawn at a lower resolution, and once almost nothing is left, it is a dashed box marked Canvas not captured.
The console shows all of it as a receipt. Over MCP, when that lands, the same receipt is a tool result.
What never leaves the browser
- The raw DOM. The screenshot is drawn in the browser from a copy that has already been masked; the copy is discarded.
- Password fields, fields whose
autocompletecontainscc-number,cc-csc,cc-exp,cc-exp-month,cc-exp-year,one-time-code,current-passwordornew-password, and anything underdata-shutterbug-maskordata-shutterbug-mask-selectors, are masked in the copy before it is drawn. The page itself is never touched. - A password field with a show-password toggle turns into a text field when shown. Give it
autocomplete="current-password"(ornew-password) and it stays masked. - To turn the browser's autofill off, some libraries set or advise
autocomplete="new-password"on comboboxes and search boxes (MUI's Autocomplete docs, among others). Those fields read as bullets in captures too. - Request headers, response headers, and request bodies. The network tail is method, URL, status and duration, plus the first 2 KB of the response body when a request fails.
- Anything on origins not in
data-shutterbug-allow-origins.
The screenshot is a private blob, kept ninety days, readable from the console and by the person who filed the report, in Your reports.
Who can report
Only signed-in people. On shutterbug.dev the console's own session is enough; on any other origin the widget fetches a ten-minute token from the host's token route and carries it on every send. A report is refused when its origin is not listed for the project, or when the token is missing, expired, or for another project.