Lightweight feedback widget for the web. Drop-in vanilla JS, React component, or <script> tag. Zero config, framework-free, tiny.
Backed by Usero. Sign up to get a clientId.
Install
npm install @usero/sdk
Usage
Vanilla JS (any framework, or none)
import { initUseroFeedbackWidget } from '@usero/sdk'
const widget = initUseroFeedbackWidget({
clientId: 'YOUR_CLIENT_ID',
position: 'right',
})
// later, if you need to remove it:
widget.destroy()
The vanilla build never imports React. Vue, Svelte, Angular, plain HTML, and Electron apps pay zero React tax.
React
import { UseroFeedbackWidget } from '@usero/sdk/react'
export function App() {
return (
<>
{/* your app */}
<UseroFeedbackWidget clientId='YOUR_CLIENT_ID' />
</>
)
}
Screenshot upload
The widget includes a screenshot upload button by default. Users can attach up to 3 images (max 10MB each, any image/* MIME type) to their feedback. Uploads go to ${baseUrl}/api/screenshots and the resulting URLs are attached to the feedback submission. Disable with showScreenshotOption: false.
Script tag (CDN)
<script src="https://unpkg.com/@usero/sdk"></script>
<script>
Usero.initUseroFeedbackWidget({ clientId: 'YOUR_CLIENT_ID' })
</script>
unpkg and jsDelivr both serve the IIFE bundle automatically. No separate hosting needed.
Theme
The widget auto-detects the OS color scheme via prefers-color-scheme. It picks the built-in dark theme on dark systems and the light theme on light systems, and swaps live if the user toggles modes while the widget is open. When no preference is reported (older browsers, SSR), it defaults to dark. Pass an explicit theme to override; explicit values always win, and partial overrides merge on top of the OS-resolved base.
Options
| Option | Type | Default | Description |
|---|---|---|---|
clientId |
string |
required | Your Usero client ID |
position |
'left' | 'right' |
'right' |
Which side of the viewport the tab sits on |
theme |
Partial<WidgetTheme> |
auto (OS color scheme, dark fallback) | Override colors. Wins over auto-detection |
title |
string |
'Share Feedback' |
Panel header |
placeholder |
string |
'Tell us what you think... (optional)' |
Comment placeholder |
showEmailOption |
boolean |
true |
Show the "share my email" checkbox |
showScreenshotOption |
boolean |
true |
Show the screenshot upload button (up to 3 images, 10MB each) |
environment |
string |
undefined | Tag feedback with an environment |
baseUrl |
string |
'https://usero.io' |
Override API host (self-hosted Usero) |
metadata |
Record<string, unknown> |
undefined | Arbitrary metadata attached to feedback |
launcherType |
'tab' | 'button' | 'none' |
'tab' |
Launcher shape: edge tab, labelled corner button, or none |
launcherLabel |
string |
falls back to title |
Text on the 'button' launcher |
hideTrigger |
boolean |
false |
Deprecated, use launcherType: 'none' |
disablePageContext |
boolean |
false |
Don't send pageUrl / pageTitle / referrer |
onSubmit |
(data) => void |
undefined | Fires after a successful submission |
onError |
(err: Error) => void |
undefined | Fires on init or submission error |
onOpen / onClose |
() => void |
undefined | Fire when the panel opens/closes |
Launcher
launcherType picks what the visitor clicks to open the panel.
'tab'(default) is the half-pill stuck to the middle of the screen edge named byposition.'button'is a labelled pill in the bottom corner on that same side.launcherLabelsets the text and falls back totitle.'none'renders no launcher. The panel, plugins and identity still mount, so open the widget yourself via the handle'sopen()(vanilla) or a ref (React).
initUseroFeedbackWidget({
clientId: 'YOUR_CLIENT_ID',
launcherType: 'button',
launcherLabel: 'Report a bug',
})
Privacy options
Two opt-in flags, both default false with no behaviour change unless you set them.
disablePageContext stops the widget capturing pageUrl, pageTitle, and referrer on submit. Useful for embeds where document.title or the URL can carry sensitive info, e.g. a desktop app shell (Tauri, Electron) whose window title holds a connection name or a local file path. The fields are omitted from the submission entirely, not sent empty.
hideTrigger hides the widget's default edge tab so you can supply your own trigger UI and open the widget programmatically. The panel, plugins, and identity still mount as normal, only the built-in tab is hidden. Deprecated in favour of launcherType: 'none', which does the same thing; launcherType wins if you set both.
import { useRef } from 'react'
import { UseroFeedbackWidget, type UseroFeedbackWidgetHandle } from '@usero/sdk/react'
function App() {
const widgetRef = useRef<UseroFeedbackWidgetHandle>(null)
return (
<>
<button onClick={() => widgetRef.current?.open()}>Send feedback</button>
<UseroFeedbackWidget
ref={widgetRef}
clientId='YOUR_CLIENT_ID'
hideTrigger
disablePageContext
/>
</>
)
}
Vanilla and headless integrations use the handle/controller they already get back from initUseroFeedbackWidget / createUseroFeedback:
import { initUseroFeedbackWidget } from '@usero/sdk'
const widget = initUseroFeedbackWidget({
clientId: 'YOUR_CLIENT_ID',
hideTrigger: true,
disablePageContext: true,
})
myOwnButton.addEventListener('click', () => widget.open())
The headless controller (createUseroFeedback / useUseroFeedback) never renders a trigger to begin with, so only disablePageContext applies there; pass it in the options object.
Session replay
Record what your users actually did, with or without the feedback widget. Recording streams rrweb events to Usero in gzipped chunks while the user is on the page, so you capture whole sessions rather than only the moments around a feedback submission. On a slow connection the SDK merges waiting chunks into larger ones instead of dropping them, and sends what it still holds when the page is hidden or closed.
Switching to another app or tab does not end the recording. The SDK sends what it has buffered and keeps recording, and when the person comes back the same session carries on, so feedback sent after an app switch still links to it. If they never come back, Usero closes the session once it has been quiet for 3 minutes (the check runs every 5), with its end time set to the last upload. A full page load, such as following a normal link to another page on your site, starts a new session; client-side route changes stay in the same one.
rrweb ships inside the replay chunk, so npm install @usero/sdk is the only install step. Replay lives in its own subpath export (@usero/sdk/replay), so consumers who never import it pay zero rrweb bytes on the base bundle. Even consumers who DO import it don't pay rrweb's bytes upfront: rrweb lazy-loads at runtime via dynamic import only once a recording actually starts.
Standalone (no widget)
import { sessionReplay } from '@usero/sdk/replay'
sessionReplay({ clientId: 'YOUR_CLIENT_ID' }).start()
That is the whole integration. No widget mounts, no UI renders, nothing is added to the DOM. If you know who the user is, pass getUser so replays show up under the right person:
const replay = sessionReplay({
clientId: 'YOUR_CLIENT_ID',
getUser: () => (auth.user ? { id: auth.user.id, email: auth.user.email } : null),
})
replay.start()
// Optional: end the recording early. Flushes buffered events and
// finalises the session server-side.
replay.stop()
getUser is re-invoked at session start and at every chunk boundary, so a login that happens mid-session is picked up without any extra wiring. Returning null after a user was identified is treated as a logout.
React
import { useSessionReplay } from '@usero/sdk/replay/react'
function App() {
useSessionReplay({ clientId: 'YOUR_CLIENT_ID' })
return <Routes />
}
The hook is SSR-safe (a no-op on the server), StrictMode-safe (the dev-mode double effect starts exactly one recording), and page-scoped: recording survives the component unmounting on client-side route changes, and ends when the page is closed. The hook returns the replay instance, so you can call .stop() to end a recording early. Options are captured on first render; to track a user who logs in mid-session, pass a getUser callback rather than changing options.
With the feedback widget
Pass the same factory to the widget's plugins array. Feedback submissions then deep-link to the exact moment in the recording where the user hit submit.
import { initUseroFeedbackWidget } from '@usero/sdk'
import { sessionReplay } from '@usero/sdk/replay'
initUseroFeedbackWidget({
clientId: 'YOUR_CLIENT_ID',
plugins: [
sessionReplay({
// Wait 3s after load before starting. If the user navigates away
// first, rrweb is never fetched and no session is created.
startAfterMs: 3000,
// Sample 50% of sessions. Decided once at init via Math.random().
sampleRate: 0.5,
}),
],
})
In plugin mode you don't pass clientId, user, or getUser: the widget's own configuration is authoritative.
The legacy import path @usero/sdk/plugins/session-replay still works and resolves to the same module. New code should import from @usero/sdk/replay.
One recording per page
At most one replay recording runs per page, whichever way it was started. The rules:
.start()is idempotent. Calling it while a recording is live (from this instance or any other on the page) is a no-op, which is also what makes the React hook StrictMode-safe.- If a recording was started standalone and the feedback widget mounts later with a
sessionReplay()plugin, the widget does NOT start a second recorder. It links to the running recording: feedback submissions deep-link into it, and the widget takes over user resolution while mounted. - A widget unmount never kills a recording it merely adopted; recordings are page-scoped.
.stop()flushes buffered events, finalises the session server-side, and tears down listeners. Calling.start()afterwards begins a new replay session..start()is a no-op without awindow(SSR) and logs an error if noclientIdwas provided.
Privacy defaults
| Option | Default | What it does |
|---|---|---|
maskAllInputs |
true |
Mask <input> and <textarea> values in the recording |
maskTextSelector |
'[data-usero-mask]' |
Mask text content of any node matching this selector |
blockSelector |
'[data-usero-block]' |
Skip recording subtrees entirely |
inlineStylesheet |
true |
Inline external stylesheets so replays render offline |
sampling |
{ mousemove: 50, scroll: 100 } |
Throttle high-frequency events |
startAfterMs |
0 |
Delay before loading rrweb and starting the session |
sampleRate |
1 |
Probability (0..1) that a given session records at all |
Tag any DOM node you want masked at the source: <div data-usero-mask>...</div>. Tag entire subtrees you want skipped with data-usero-block.
Recording: always, ask first, or never
Whether visitors are recorded is a project setting in Usero (Settings, Session replay, Recording), fetched on every page load, so you change it without shipping code:
- Always (default): record in the background. If no recording is running when someone sends feedback (sampled out, or
startAfterMsnot reached yet), the feedback sends first, then the widget takes a page snapshot (one masked frame of the page) and Usero links it to that feedback. - Ask first: nothing is recorded in the background. The feedback sends first, then the widget asks "Thanks, it's sent! Want to include a snapshot of this page too?" with Include and Don't include buttons. Nothing is captured until they pick Include. Don't include, Escape, or 15 seconds with no answer means nothing is captured at all.
- Never: nothing is recorded, no snapshot is taken, rrweb is never loaded, and Usero refuses new recordings for the project, even from a cached widget.
Replay never slows down sending feedback. Nothing is captured or requested before the feedback POST, and the snapshot is taken after the thank-you message has painted, with the widget and the Ask first prompt left out of it. The snapshot's session is created with the feedback id and a one-time token from the feedback response, so Usero links the two however long the upload takes, and only the browser that sent the feedback can attach a snapshot to it. Recording chunks, the snapshot's included, upload as POST requests with no CORS preflight.
If the settings fetch fails, the widget falls back to Always, the behaviour from before this setting existed. To pin the mode in code, pass recording: 'always' | 'ask' | 'never' to sessionReplay(). It skips the settings fetch, and Usero still refuses anything the project setting does not allow. The prompt wording is exported as RECORDING_CONSENT_COPY and the type as RecordingMode.
Standalone-only options: clientId (required for .start()), user / getUser (identify the current user), and apiUrl (override the API host, defaults to https://usero.io). Advanced chunking knobs (chunkSeconds, chunkMaxEvents, chunkMaxBytes, chunkMaxAttempts, checkoutEveryMs) are documented in the SessionReplayOptions type.
Headless (bring your own UI)
The widget's look is configurable but its structure is fixed: a launcher button, a panel, our copy. If you want full control, a modal that matches your design system, a form embedded in your settings page, your own copy, import the headless core instead. You get the same submission pipeline, identity handling, and plugin support as the widget, with zero UI and zero opinion about how feedback gets collected.
import { createUseroFeedback } from '@usero/sdk/headless'
const usero = createUseroFeedback({ clientId: 'YOUR_CLIENT_ID' })
// Wire this to your own form's submit handler.
const result = await usero.submit({ rating: 4, comment: 'Love the new dashboard' })
if (!result.success) showError(result.error)
submit() validates the payload (a submission needs a rating or a non-empty comment), captures page context (URL, title, referrer) automatically, runs the plugin pipeline, and POSTs to Usero. It resolves with { success: false, error } instead of throwing, for validation and network failures alike, so your form can render the message directly.
There is deliberately no open(), close(), or isOpen. When you own the UI, you own its state.
React
import { useUseroFeedback } from '@usero/sdk/headless/react'
function FeedbackModal({ onDone }: { onDone: () => void }) {
const usero = useUseroFeedback({ clientId: 'YOUR_CLIENT_ID' })
const [comment, setComment] = useState('')
async function handleSubmit(e: FormEvent) {
e.preventDefault()
const result = await usero.submit({ comment })
if (result.success) onDone()
}
return <form onSubmit={handleSubmit}>{/* your design system here */}</form>
}
The hook is SSR-safe (the controller is created in an effect, never on the server), StrictMode-safe, and destroys the controller on unmount. Options are captured on first render, except user, which stays reactive: pass the current user object (or null on logout) and the SDK re-identifies when it changes by value.
Session replay with your custom UI
Pass sessionReplay() into plugins and every submission from your custom UI deep-links to the exact moment in the recording, the same as it does with our widget. The plugin pipeline runs on every submit(), so the replay plugin attaches sessionReplayId and replayOffsetMs for you.
import { createUseroFeedback } from '@usero/sdk/headless'
import { sessionReplay } from '@usero/sdk/replay'
const usero = createUseroFeedback({
clientId: 'YOUR_CLIENT_ID',
plugins: [sessionReplay()],
})
Feedback UI inside an open ShadowRoot is recorded without any extra call, whether you attach the root before or after its host joins the page. usero.notifyShadowMount(root) still exists, so existing calls keep working, but since 1.6.1 recording no longer needs it.
Screenshots
Collect a File however you like (a file input, a paste handler, your own capture flow), upload it, and include the result in a later submit:
const screenshot = await usero.uploadScreenshot(file) // throws on failure
await usero.submit({ comment: 'Broken layout', screenshots: [screenshot] })
Identity
usero.identify({ id: user.id, email: user.email, traits: { plan: 'pro' } })
usero.identify(null) // logout: rotates the anonymousId
Identify calls are deduped, so calling on every render or route change is free when nothing changed. You can also pass user or getUser in the options instead; getUser is re-resolved at submit time, so a login that happens after creation is picked up without extra wiring.
Full surface
createUseroFeedback(options) takes clientId (required), apiUrl, environment, metadata (attached to every submission, deep-merged one level with per-submission metadata), plugins, and user / getUser. The returned controller has submit, uploadScreenshot, identify, whenReady (resolves when every plugin's onInit has settled), notifyShadowMount, and destroy. Everything is typed: SubmitFeedbackPayload, SubmissionResponse, ScreenshotData, UseroUser, UseroPlugin, and friends are all exported from @usero/sdk/headless.
The headless entry is its own subpath export with no widget CSS, no React, and no rrweb: 3.2KB gzipped minified.
Plugins
The widget has a tiny plugin API for opt-in features that would otherwise bloat the base bundle. Plugins live in subpath exports so the base widget stays small for everyone who doesn't use them. Session replay (above) is the flagship plugin; it doubles as a standalone recorder.
Writing your own plugin