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, 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 30 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. Small viewports — below minViewportWidth (default 768 px) it skips with onSkip("mobile"). 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.

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. Seven public events are available:

EventPayloadFires when
feedback:sentthe created feedbackA submission succeeded
feedback:deletedthe feedback idA feedback was deleted
feedback:errorthe ErrorA feedback API call failed — same payload as the onError config callback
panel:open / panel:closeThe panel opened / closed
annotation:start / annotation:endAn 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, onError, onOpen, onClose, onAnnotationStart, onAnnotationEnd, onSkip) is read at call time, so a handler that closes over fresh state fires with that state. 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") and retryable (boolean):

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.

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. The queue stores payloads only — never tokens or headers, which are recomputed at flush time.
  • Both apply to HTTP mode (endpoint). In client-side store mode writes are local, so there is nothing to retry.
Edit on GitHub

On this page