@userz-ai/browser
Framework-free widget core. Use directly for vanilla JS, Vue, Svelte, Solid, or as the peer dep behind @userz-ai/react.
Install
pnpm add @userz-ai/browserCore Web Vitals are measured with the web-vitals library, which installs with the SDK.
Or via CDN, see CDN bundle below.
Five-minute quick start
Drop this into any page that has the App's public key. The widget mounts the bubble in the bottom-right; clicking it opens the panel. Submissions hit POST /v1/feedback immediately.
import { createUserz } from '@userz-ai/browser';
const userz = createUserz({
publicKey: 'pub_GLLHdYCLb2A2d9ieY2LmmwRJ',
});
// Optional: tag every submission with route + build info.
userz.setMetadata({
route: window.location.pathname,
build: import.meta.env.VITE_BUILD_SHA,
});
// Optional: identify the current end-user (public mode).
userz.setUser({ email: '[email protected]', name: 'Jane Doe' });Done. The widget handles the panel UI, screenshot capture, console-log buffering, presigned attachment upload, and the submission POST. You don't have to wire any of those up yourself.
createUserz(config)
The single entry point. Returns a Userz instance.
Config
| Name | Type | Description |
|---|---|---|
| publicKey* | string | Your App's public key, starts with pub_. |
| getUserToken | () => string | undefined | Promise<…> | Async getter for the private-mode JWT. Return undefined when no end-user is signed in; the widget will fall back to public mode if that's enabled on the App. Called on every submit, so you can return a fresh value from your auth store each time. |
| bubble | boolean | Render the floating bubble button. Default true. |
| showEmailField | boolean | Show the optional "your email" input in public mode. Default true. |
| consoleCapacity | number | Max console log entries kept in the ring buffer. Default 200. |
| captureErrors | boolean | Capture window.error and unhandledrejection into the console buffer. Default true. |
| targetingChord | string | Key chord that activates the component-targeting overlay. Default "Ctrl+Shift+U". |
| mode | 'hover' | 'session' | 'single' | Submission mode. From @userz-ai/browser 2.1, an install that passes no mode is in hover mode; an explicit value is never re-defaulted. Absent locally, the App's configuration document (widget.mode) decides. See Modes. |
| initialUser | UserIdentity | Initial identity for public-mode submissions. Updatable via setUser(). |
| brandColor | string | Override the widget accent (bubble bg + primary button). Any CSS color. Defaults to Userz ink #0a0a0a. See Theming below. |
| brandForeground | string | Override the text color rendered on top of brandColor. Auto-derived from brandColor luminance if omitted. |
| backgroundColor | string | Override the widget panel surface (panel bg, inputs, cards). When set, the widget uses a single palette regardless of OS prefers-color-scheme. See Theming below. |
| textColor | string | Override the main panel text color. Pair with backgroundColor for full surface white-labeling. Either one alone is symmetric — the un-supplied side is auto-derived from luminance. |
| screenshot.hostCapture | () => Promise<Blob | ImageBitmap | HTMLCanvasElement | null> | For apps that run inside a desktop or native shell (Electron, Tauri, a native webview): a pixel capture of the page's viewport, taken by the shell. When set, every screenshot is taken through it. See Screenshots in a desktop shell. |
Returned instance methods
| Name | Type | Description |
|---|---|---|
| open() | void | Show the feedback panel. |
| close() | void | Hide the panel. |
| toggle() | void | Toggle visibility. Same thing the bubble does. |
| setUser(user) | (UserIdentity | null) => void | Update the public-mode identity (email, name, externalUserId). Pass null to clear. |
| setMetadata(meta) | (Record<string, unknown> | null) => void | Attach arbitrary metadata to the next submission (route, feature flags, build SHA, etc.). ≤ 4 KB JSON. |
| submit(input) | (SubmitInput) => Promise<{ id, status }> | Programmatic submission, bypasses the panel UI. Throws on validation or network error. |
| captureScreenshot(target?) | (Element?) => Promise<{ blob, width, height }> | Captures the visible viewport, or crops to the target element. Rejects with ScreenshotUnavailableError when the page is too large to capture safely. See Screenshot capture below. |
| setTargetingEnabled(on) | (boolean) => void | Show/hide the component-targeting overlay. |
| registerTarget(t) | ({ el, name, meta? }) => () => void | Register a click-target for the overlay. Returns an unregister fn. |
| destroy() | void | Tear down the widget, restore console, remove the host node. |
Type definitions
The wire types are stable and re-exported from the package root:
type Severity = 'low' | 'medium' | 'high';
interface UserIdentity {
externalUserId?: string;
email?: string;
name?: string;
}
interface AttachmentRef {
key: string; // upload key from the upload-url endpoint
kind: 'screenshot' | 'annotated' | 'attachment';
contentType: string;
byteSize: number;
width?: number;
height?: number;
}
interface ComponentTargetData {
name: string;
meta?: Record<string, unknown>;
bbox?: { x: number; y: number; w: number; h: number };
screenshotKey?: string; // upload key of cropped screenshot
}
interface ConsoleLogEntry {
level: 'log' | 'info' | 'warn' | 'error' | 'debug';
message: string;
ts: number; // epoch ms
}
interface SubmitInput {
text: string; // 1–8192 chars
severity?: Severity | null;
identity?: UserIdentity; // public mode only
metadata?: Record<string, unknown>; // ≤ 4 KB JSON
attachments?: AttachmentRef[]; // ≤ 10
componentTarget?: ComponentTargetData;
withScreenshot?: boolean; // capture the viewport first; never fails the submit
}Programmatic submission (no panel)
You don't have to use the bubble or panel, call submit() directly from your own UI. The widget still attaches console logs, captures the viewport, and (optionally) takes a screenshot for you.
document.querySelector('#bug-button')?.addEventListener('click', async () => {
try {
const { id, status } = await userz.submit({
text: 'Cart total shows NaN after applying coupon',
severity: 'high',
withScreenshot: true,
metadata: { cartId: state.cart.id, coupon: state.coupon.code },
});
toast.success(`Reported. Tracking id: ${id}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not submit feedback');
}
});bubble: false in the config if you only want the programmatic path, the widget will still mount the panel host (for screenshot rendering) but won't show the floating button.Modes
One mode option decides how a user starts a submission. Every mode ends in the same form and the same POST /v1/feedback; what differs is the gesture and whether an element is attached to the item.
| Name | Type | Description |
|---|---|---|
| hover | default from 2.1 | On a pointer device, resting the pointer over one element for a short dwell reveals a small indicator beside it; clicking the indicator (or Enter / Space with it focused) opens the form anchored to that element, with the element described on the submitted item. Hovering never opens the form and page clicks are never intercepted; Escape dismisses, and three dismissals mute the mode for the tab session. On a touch device the bubble launches an explicit tap-to-pick: a tap highlights the element under the finger, a confirm opens the form with it described. With a mouse, the bubble click opens the one-shot form. |
| session | explicit | The bubble click toggles session mode: the user right-clicks any element to add it as one item, several items collect in a session panel, and "Submit all" ships them together. The richer flow for product-feedback walkthroughs. |
| single | explicit | The bubble click opens a one-shot form. One item, no element picker, no session — a classic feedback button. |
The default is chosen where it is read: an install that passes no mode takes the App's configuration document's widget.mode when the document carries one, and hover otherwise. Set mode explicitly to opt out of both — an explicit value is never re-defaulted, by a document or by an upgrade.
In hover and single mode the session-mode methods (setSessionMode, getSessionItems, …) are silent no-ops, submitSession() rejects with a clear error, and the session chord is not registered. Onboarding copy follows the active mode — it names the gesture of the mode you are in, never a second flow.
mode is in session mode. Pass mode: 'hover' on those builds to opt in early.Theming
Four init options drive the widget's CSS custom properties on its Shadow DOM host. Pass any combination — un-supplied values fall back to the built-in light/dark defaults.
| Name | Type | Description |
|---|---|---|
| brandColor | --uz-primary | Bubble background, primary button, focus rings. Default #0a0a0a (ink). |
| brandForeground | --uz-primary-fg | Text on the primary button. Auto-derived from brandColor luminance if omitted. Default #fafafa. |
| backgroundColor | --uz-bg | Panel surface, inputs, cards. Default #ffffff (light) / #0e0f12 (dark). |
| textColor | --uz-fg | Main panel text. Default #0a0a0a (light) / #f5f5f5 (dark). |
createUserz({
publicKey,
brandColor: '#ff5500', // accent (bubble + submit button)
brandForeground: '#ffffff', // text on the accent
backgroundColor: '#0a0a0a', // panel surface
textColor: '#f5f5f5', // panel text
});Muted-label (--uz-muted) and border (--uz-border) colors auto-derive from textColor + backgroundColor via color-mix(in oklch, …) so secondary text and dividers stay in harmony with the customer palette. Severity dots (--uz-sev-*), annotation preset colors, and onboarding accent stay fixed — they're semantic, not brand.
backgroundColor or textColor is set, the widget uses one palette regardless of the visitor's OS prefers-color-scheme. Read the preference yourself and pass the matching set if you want light/dark switching.If you pass only one of brandColor / brandForeground (or one of backgroundColor / textColor), the widget picks the other from the paired color's WCAG-style relative luminance — so a one-line accent override won't accidentally land low-contrast text on top.
Console + error capture
On creation the widget wraps console.log/info/warn/error/debug with a ring buffer (default 200 entries, 1 KB each). The originals are still called, your devtools see exactly what they would have. The captured slice rides along with every submission so the AI agent has runtime context.
If captureErrors is on (default), window.error and unhandledrejection are also written to the buffer with prefix tags.
Screenshot capture
A screenshot is what the user sees: the visible viewport, or an element cropped out of it. It is taken in the page by modern-screenshot, which is dynamically imported on the first capture so apps that never take one don't pay for it.
The capture costs the same on any page. Only what is on screen is cloned, so a chat transcript, a log or a feed thousands of rows long costs no more than its visible part. Each element in the clone is then checked against its position on the live page, so long scroll positions, fixed headers and sticky elements come out where the user sees them. It works under a strict Content-Security-Policy with no extra directives beyond the ones in CSP.
If the visible part of a page is still too large to clone safely (tens of thousands of elements on screen at once), the capture is refused with a ScreenshotUnavailableError rather than slowing the page down. The panel says so, and the feedback is sent without a screenshot.
import { ScreenshotUnavailableError } from '@userz-ai/browser';
// Capture the whole viewport
const { blob, width, height } = await userz.captureScreenshot();
// Capture a specific element
const { blob: cart } = await userz.captureScreenshot(document.querySelector('#cart')!);
// A page too large to capture is refused by name, never by freezing the tab.
try {
await userz.captureScreenshot();
} catch (err) {
if (err instanceof ScreenshotUnavailableError) {
// err.reason: 'too-large' | 'too-slow'
}
}
// Hand it to submit() yourself if you want to render a preview first.
// (Otherwise pass withScreenshot: true and the widget handles the upload.)Screenshots in a desktop shell
If your app runs inside a desktop or native shell, the shell can take a real pixel capture of its own window. That capture includes canvases, video and cross-origin frames, and costs the same however large the page is. Pass it as screenshot.hostCapture. The widget hides itself for the frames the capture takes, then crops the image like any other screenshot. Return the page's viewport (the web contents, without window chrome) at any pixel density. If the function throws or returns null, that screenshot is taken in the page instead.
In Electron, the main process captures and the preload script exposes it:
// main.ts — capture the window the request came from
import { BrowserWindow, ipcMain } from 'electron';
ipcMain.handle('app:capture-window', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || !win.isVisible() || win.isMinimized()) return null;
const image = await win.webContents.capturePage();
if (image.isEmpty()) return null;
return image.toPNG({ scaleFactor: Math.max(1, ...image.getScaleFactors()) });
});
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('appShell', {
captureWindow: () => ipcRenderer.invoke('app:capture-window'),
});
// renderer
const userz = createUserz({
publicKey: 'pub_…',
screenshot: {
hostCapture: async () => {
const png = await window.appShell.captureWindow();
return png ? new Blob([png], { type: 'image/png' }) : null;
},
},
});Component targeting overlay
Press Ctrl+Shift+U (configurable) to activate the overlay. Every element registered via registerTarget() gets a pulsing rectangle. Clicking one captures a cropped screenshot of just that element and pre-fills the panel with the target's name + meta.
const unregister = userz.registerTarget({
el: document.querySelector('#checkout-button')!,
name: 'CheckoutButton',
meta: { variant: 'A', plan: 'pro' },
});
// Later, when the element unmounts:
unregister();For React apps, use <UserzTarget> from @userz-ai/react rather than calling registerTarget directly.
Private-mode (authenticated) submissions
When your App has publicSubmissionEnabled: false, every submission must carry a JWT proving the end-user is allowed to submit feedback. Mint it on your backend (see @userz-ai/node), expose it to the browser, and return it from getUserToken:
// Common pattern: refresh on every submit so token rotation just works.
const userz = createUserz({
publicKey: 'pub_...',
getUserToken: async () => {
if (!authStore.userzToken || authStore.userzExpiresAt < Date.now() + 60_000) {
await authStore.refreshUserzToken(); // calls your /me endpoint
}
return authStore.userzToken;
},
});See the tokens reference for the JWT shape and lifetime rules.
Vue 3 recipe
// composables/useUserz.ts
import { createUserz, type Userz } from '@userz-ai/browser';
import { onBeforeUnmount, onMounted, ref } from 'vue';
export function useUserz() {
const instance = ref<Userz | null>(null);
onMounted(() => {
instance.value = createUserz({
publicKey: import.meta.env.VITE_USERZ_PUB!,
});
});
onBeforeUnmount(() => instance.value?.destroy());
return instance;
}Svelte 5 recipe
// userz.svelte.ts
import { createUserz, type Userz } from '@userz-ai/browser';
import { onMount } from 'svelte';
let userz = $state<Userz | null>(null);
onMount(() => {
userz = createUserz({
publicKey: import.meta.env.VITE_USERZ_PUB!,
});
return () => userz?.destroy();
});
export function getUserz() { return userz; }Errors thrown by submit()
The widget surfaces the API's error code as the Error.message. Pattern match on the code, not the human-readable suffix.
| Name | Type | Description |
|---|---|---|
| invalid_body | 400 | Text empty / too long, attachment count > 10, metadata > 4 KB, etc. |
| unknown_app | 401 | publicKey is wrong or the App was deleted. |
| invalid_user_token | 401 | JWT signature failed, expired, or the app claim doesn't match this App. |
| origin_not_allowed | 403 | Page Origin isn't on the App's allowed origins list. |
| public_disabled | 403 | No JWT supplied and the App requires private mode. |
| rate_limited | 429 | Per-IP or per-user limit exceeded. Back off and retry. |
CDN bundle
For pages without a build step we publish a script-tag bundle that exposes a single global, window.Userz. Current release: @userz-ai/browser@2.0.5.
<!-- Pinned to 2.0.5 and integrity-checked (recommended) -->
<script src="https://cdn.userz.ai/2.0.5/userz.js" async
integrity="sha384-mCCbjuidh0mf4FjihNSGK6bjO5ujG6botvdktnyIT6eo5x2Emz5z4IYHn1DnG4m5" crossorigin="anonymous"></script>
<script>
window.addEventListener('load', () => {
Userz.createUserz({
publicKey: 'pub_...',
});
});
</script>The URL names an exact version, so the bytes behind it never change and the integrity value stays valid. If the file is altered in transit or at the CDN, the browser refuses to run it. To upgrade, replace both the URL and the integrity value with the ones shown here for the new release.
The core loads the rest of the SDK (the feedback widget, telemetry, session replay) from the same versioned directory when a feature needs it. The core checks each file against a digest built into it, so pinning the core pins everything it loads. Allow the CDN host in script-src. See Content-Security-Policy.
Self-contained build
Some pages cannot load code at run time, such as a Manifest V3 browser extension or a strict Content-Security-Policy. For those, userz.full.js includes every feature in one file and fetches nothing else.
<script src="https://cdn.userz.ai/2.0.5/userz.full.js" async
integrity="sha384-NQcu9uQNKQDtEitT4mhwboCDqumZO+uwVE+HeW17iS4fZryzwONIWJQO0S7buTaH" crossorigin="anonymous"></script>Rolling URL
https://cdn.userz.ai/2/userz.js always serves the newest 2.x release and is revalidated every five minutes. Because its bytes change, it cannot carry an integrity attribute. Use it only where you accept upgrades without review. https://cdn.userz.ai/v1/userz.js is frozen at 0.3.5 for existing V1 integrations; it never receives a 2.x release.
Browser support
The SDK supports browsers released since 2023: Chrome/Edge 120+, Firefox 120+, Safari 17+ — a fixed floor, held by the build target and a syntax check of every shipped asset, revised only by a recorded decision. Features above it (constructed stylesheets, fetchLater(), the Popover API) are used behind detection with a complete fallback. No polyfills shipped. Details, enforcement and known gaps inside the floor are on the browser support page; a strict Content-Security-Policy is covered on the CSP page.