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/widget
import { 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):

  1. Server-side rendering — no window? The widget skips with onSkip("ssr"). Never bypassed.
  2. Already initialized — a second initSiteping() call returns the existing instance (one widget per page). destroy() resets this.
  3. Production — when process.env.NODE_ENV === "production", the widget skips with onSkip("production"). This is the "clients see it during review, visitors never do" default. Bypass with forceShow: true for staging/preview environments. Note: only process.env.NODE_ENV is read — no import.meta.env.
  4. Minimum width (opt-in) — below minViewportWidth it skips with onSkip("mobile"). The default, 0, renders at every width: phones get the compact layout. Also bypassed by forceShow.
  5. Config validation — no endpoint/store, or no projectName? The widget logs a console.error and no-ops. This one does not call onSkip — check the console, not the callback.

Changed in 0.11: minViewportWidth used to default to 768, which kept the widget off phones. A site that upgrades without setting it now shows the widget on phones too, and onSkip("mobile") no longer fires. Set minViewportWidth: 768 to keep the previous behaviour. With enableRightClickComment on, 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 globals

on() returns an unsubscribe function. Eight public events are available:

EventPayloadFires when
feedback:sentthe created feedbackA submission succeeded
feedback:deletedthe feedback idA feedback was deleted
comment:addedthe stored commentA reply was posted from a feedback's discussion thread
feedback:errorthe ErrorA 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 open and in_progress, Resolved covers resolved and wont_fix.
  • Scope: This page (the default), This type — every page sharing the current route template, offered when getPageScope returns a urlPattern — 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 onError and 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 headers factory must settle within 10 s. Retries and the queue apply to HTTP mode (endpoint). In client-side store mode nothing is retried or queued: the widget stops waiting on the store's createFeedback after 30 s and reports a retryable error (code "TIMEOUT"), but cannot cancel the call — a network-backed custom store should bound its own calls.
Edit on GitHub

On this page