Widget
Install the feedback widget, understand when it shows, and control it from your app.
The widget is the part your clients see: a floating button that lets them draw a rectangle on the page — or, on a phone, tap an element — type a comment, and send it, pinned to the exact element.
npm i @siteping/widgetimport { initSiteping } from "@siteping/widget";
const siteping = initSiteping({
endpoint: "/api/siteping",
projectName: "my-project",
});initSiteping is the package's main runtime export, alongside the two i18n helpers (registerLocale, loadLocale) and the TypeScript types. It weighs about 34 KB gzipped (ESM), lazy-loads its heavier parts (panel, locales, screenshot engine), and renders inside a closed Shadow DOM so your page styles and the widget's never collide.
When the widget shows — and when it doesn't
initSiteping runs a series of guards, in this order. When one skips, you get a no-op instance back (every method exists, nothing renders):
- Server-side rendering — no
window? The widget skips withonSkip("ssr"). Never bypassed. - Already initialized — a second
initSiteping()call returns the existing instance (one widget per page).destroy()resets this. - Production — when
process.env.NODE_ENV === "production", the widget skips withonSkip("production"). This is the "clients see it during review, visitors never do" default. Bypass withforceShow: truefor staging/preview environments. Note: onlyprocess.env.NODE_ENVis read — noimport.meta.env. - Minimum width (opt-in) — below
minViewportWidthit skips withonSkip("mobile"). The default,0, renders at every width: phones get the compact layout. Also bypassed byforceShow. - Config validation — no
endpoint/store, or noprojectName? The widget logs aconsole.errorand no-ops. This one does not callonSkip— check the console, not the callback.
Changed in 0.11:
minViewportWidthused to default to768, which kept the widget off phones. A site that upgrades without setting it now shows the widget on phones too, andonSkip("mobile")no longer fires. SetminViewportWidth: 768to keep the previous behaviour. WithenableRightClickCommenton, a long-press on an Android phone now opens the composer as well.
On phones and tablets
The same widget adapts to the screen it lands on — there is nothing to configure.
Touch screens (any size):
- Tap an element to comment on it, or drag to select an area. The instruction bar says so instead of mentioning the Enter key.
- The floating menu shows its labels (there is no hover to reveal them), and every control is sized for a finger.
- Form fields use 16 px text, so iOS Safari doesn't zoom the page when one is focused.
Phones (viewport up to 640 px wide, or a touch screen up to 500 px tall — a phone held sideways):
- The feedback form is a bottom sheet that stays above the on-screen keyboard. If it would cover what the client just selected, the page scrolls so the selection stays in view.
- The feedback panel is a bottom sheet over the dimmed page — close it with the ✕, by tapping the page, or by swiping its header down. Filters fit on one scrollable row, and the type and sort menus open as action sheets.
- Tapping a pin on the page opens that feedback's details directly. Go to annotation closes the sheet and scrolls to the pin, wherever the page's layout put it on this screen.
- The name-and-email prompt is a bottom sheet too.
The layout respects the notch and home indicator when your page sets viewport-fit=cover in its viewport meta tag. To keep the widget off small screens altogether, set minViewportWidth (for example 768).
The instance
const siteping = initSiteping({ ... });
siteping.open(); // open the feedback panel
siteping.close(); // close it
siteping.refresh(); // re-fetch feedbacks for the current page (never throws)
siteping.focusFeedback(id); // scroll to + highlight a marker; false if unknown
const off = siteping.on("feedback:sent", (feedback) => { ... });
siteping.destroy(); // full teardown, restores all patched globalson() returns an unsubscribe function. Eight public events are available:
| Event | Payload | Fires when |
|---|---|---|
feedback:sent | the created feedback | A submission succeeded |
feedback:deleted | the feedback id | A feedback was deleted |
comment:added | the stored comment | A reply was posted from a feedback's discussion thread |
feedback:error | the Error | A feedback API call failed — same payload as the onError config callback, which additionally receives panel action failures |
panel:open / panel:close | — | The panel opened / closed |
annotation:start / annotation:end | — | An annotation session started / ended. end fires whether the user submitted or cancelled, and both entry paths — the FAB and right-click comments — emit the symmetric pair, so you can pause chat bubbles or analytics overlays in between |
React
Use the dedicated hook — it survives StrictMode double-mounts and lets callbacks change between renders without re-initializing:
"use client";
import { useSiteping } from "@siteping/widget/react";
export function Feedback() {
const siteping = useSiteping({
endpoint: "/api/siteping",
projectName: "my-project",
onFeedbackSent: (f) => console.log("new feedback", f.id),
});
return null; // the widget renders itself
}The hook returns null until it mounts, then the instance. Changing endpoint or another structural option requires a remount; changing a callback does not — every callback the config accepts (onFeedbackSent, onCommentAdded, onError, onOpen, onClose, onAnnotationStart, onAnnotationEnd, onSkip) is read at call time, so a handler that closes over fresh state fires with that state — and so are the panel actions callbacks. They also go quiet after unmount, so a late response can't call into a torn-down tree.
Handling errors
onError receives errors with two useful fields — code ("NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | "TIMEOUT", the last one only in store mode) and retryable (boolean). An AUTH error also carries the HTTP status: 401 when the credentials are missing or dead, 403 when they work and the server's policy refuses the request:
initSiteping({
endpoint: "/api/siteping",
projectName: "my-project",
onError: (error) => {
const code = (error as { code?: string }).code;
if (code === "AUTH") console.warn("check your apiKey");
},
});Branch on error.code, not instanceof — the error classes themselves are not exported from the package. When the user cancels the identity prompt, no error fires: that's a cancellation, not a failure.
Statuses, from the widget's side
Feedback has four statuses (open, in_progress, resolved, wont_fix). The widget displays all four but its own actions are deliberately binary: resolve and reopen. The in-between states belong to your triage workflow in the dashboard.
Filtering the panel
The panel's filters combine, and narrow the list only — the markers on the page keep showing the page's feedback:
- Search matches the message text.
- Type keeps one feedback type.
- Status: Open covers
openandin_progress, Resolved coversresolvedandwont_fix. - Scope: This page (the default), This type — every page sharing the current route template, offered when
getPageScopereturns aurlPattern— or All pages. - Mine keeps the feedback sent from this browser.
"Mine" needs no identity and no server support, in HTTP and store mode alike. The widget remembers the id of each feedback it sends — the 500 most recent per project and endpoint, in localStorage, those the offline queue sends later included — and filters on its side: it walks the list's pages until it has found them all, then shows them at once. The server can't do it for the widget: its public GET blanks author emails, and a filter by email would let anyone check whether an address sent feedback. So "Mine" means this browser: feedback sent from another browser or device, or before the site's data was cleared, isn't listed.
Over your modals
Clients often need to report a problem inside a dialog. The widget stays usable on top of an open modal — tested against Radix / shadcn/ui Dialog, and built for any modal that works the same way (outside-click dismissal, focus trap, scroll lock, hidden siblings):
- its button, annotation overlay, comment popup and panel stay clickable, even when the modal makes the rest of the page click-through (
body { pointer-events: none }); - drawing, typing, clicking or scrolling in the widget neither closes the modal nor lets it pull the focus back;
- Escape closes the widget's open layer first (menu, panel, comment popup, annotation mode); with nothing open in the widget, it closes your modal as usual;
- the widget stays exposed to screen readers when the modal hides the rest of the page (
aria-hidden,inert).
The widget's own pointerdown, mousedown, touchstart, touchend, click, focusin, focusout, wheel and touchmove events stop at the widget, modal or not: bubble-phase listeners on your page's document or window never see them. So do the keydown, keypress and keyup of the keys typed in a widget field, Tab and Escape excepted, so a single-key shortcut on your page (/ for search, s…) no longer fires, and swallows the character, while you type in the widget. Capture-phase listeners still see all of these, and other events (pointerup, mouseup, keys pressed outside a field…) keep propagating.
Beyond that, the widget steps in only while a modal is open — a visible aria-modal="true" element, or a click-through <body> — so on a page without one, your fields' own focus and blur handlers see every event. While a modal is open, capture-phase listeners no longer see the widget's pointerdown and focusin either, and moving the focus from one of the modal's fields into the widget doesn't fire that field's focusout, so React's onBlur doesn't run; its native blur still fires. That is what keeps the modal's focus trap from pulling the focus back.
Limits: a native <dialog> opened with showModal() makes everything outside it inert, and no script can undo that — the widget can't be used while such a dialog is open. And a modal that intercepts outside mousedown, click or Tab keys before they reach the page (capture-phase listeners, as focus-trap does by default) can still get in the widget's way.
Reliability details you get for free
- Retry with backoff — failed submissions retry 3 times (10 s timeout, exponential backoff with jitter). Server errors and network failures retry; validation errors don't.
- Offline queue — when a submission still fails after the retries, the payload is queued in localStorage (up to 20 entries) and flushed on the next page load. Only transient failures are queued (network errors, 5xx): a 4xx is the server's verdict on the payload and would fail identically on replay, so it is reported through
onErrorand never queued. Every resend from the same popup keeps the feedback's id, so it replaces its queued attempt and the server never stores it twice. The queue stores payloads only — never tokens or headers, which are recomputed at flush time. If localStorage runs out of space, queued screenshots are dropped first (oldest first), then the oldest entries, and a console warning says what was dropped. - Nothing hangs a send — its response body gets a 10 s window too (a server that sends its headers and then stalls counts as a timeout), and a
headersfactory must settle within 10 s. Retries and the queue apply to HTTP mode (endpoint). In client-sidestoremode nothing is retried or queued: the widget stops waiting on the store'screateFeedbackafter 30 s and reports a retryable error (code"TIMEOUT"), but cannot cancel the call — a network-backed custom store should bound its own calls.