@userz-ai/react
Idiomatic React bindings around @userz-ai/browser. A provider, a hook, and a component-targeting wrapper.
Install
pnpm add @userz-ai/react @userz-ai/browser
# Optional peer for the in-widget annotation editor
pnpm add tldrawReact 18 and 19 are both supported.
Five-minute quick start
Wrap your tree, then call methods from anywhere underneath:
// app/Userz.tsx, Next.js client component
'use client';
import { UserzProvider } from '@userz-ai/react';
export function Userz({ children }: { children: React.ReactNode }) {
return (
<UserzProvider publicKey="pub_GLLHdYCLb2A2d9ieY2LmmwRJ">
{children}
</UserzProvider>
);
}// Anywhere underneath:
import { useUserz } from '@userz-ai/react';
function Header() {
const userz = useUserz();
return <button onClick={() => userz.open()}>Send feedback</button>;
}<UserzProvider>
Wraps your React tree, owns one Userz instance for the lifetime of the component, tears it down on unmount.
Props
Accepts every UserzConfig field from @userz-ai/browser (publicKey, getUserToken, bubble, showEmailField, consoleCapacity, captureErrors, targetingChord, mode, initialUser, plus the four theming props brandColor / brandForeground / backgroundColor / textColor — see browser docs › Theming) plus React's children.
setUser() / setMetadata() via the useUserz() hook for runtime updates.useUserz()
Returns the singleton Userz instance. Throws if called outside a <UserzProvider>.
import { useUserz } from '@userz-ai/react';
function FeedbackButton() {
const userz = useUserz();
return <button onClick={() => userz.open()}>Send feedback</button>;
}<UserzTarget>
Mark a child element as a "feedback target" the end-user can click on while the targeting overlay is active (Ctrl+Shift+U by default). On click, the panel opens pre-filled with the target's name and an opt-in meta payload, plus a cropped screenshot of just that element.
import { UserzTarget } from '@userz-ai/react';
<UserzTarget name="CheckoutButton" meta={{ variant, plan: 'pro' }}>
<button onClick={onCheckout}>Checkout</button>
</UserzTarget>Props
| Name | Type | Description |
|---|---|---|
| name* | string | Display name surfaced in feedback as the targeted-component label. |
| meta | Record<string, unknown> | Opt-in metadata forwarded with feedback. Keep it small + non-sensitive, it ships to our backend in plaintext. |
| children* | ReactNode | A SINGLE child element. We clone it and attach a ref, no wrapper div, layout is unchanged. |
<button>, <div>) and forwardRef components are fine. Plain function components without forwardRef get an invisible display: contents wrapper, your layout still works, but you should forwardRef when possible.Recipe: bridging your auth state
The setUser() call associates submissions with your user record in public mode. Mount this once near the top of your app:
import { useEffect } from 'react';
import { useUserz } from '@userz-ai/react';
export function UserzIdentityBridge({ user }: { user: User | null }) {
const userz = useUserz();
useEffect(() => {
userz.setUser(
user ? { externalUserId: user.id, email: user.email, name: user.name } : null,
);
}, [user, userz]);
return null;
}Recipe: route + build metadata
Stamping every submission with the current route, build SHA, and feature-flag set gives the AI agent better context when reproducing the issue.
import { useEffect } from 'react';
import { useLocation } from 'react-router'; // or 'next/navigation'
import { useUserz } from '@userz-ai/react';
export function UserzRouteMetadata() {
const userz = useUserz();
const location = useLocation();
useEffect(() => {
userz.setMetadata({
route: location.pathname,
build: import.meta.env.VITE_BUILD_SHA,
flags: featureFlags.activeIds(),
});
}, [location.pathname, userz]);
return null;
}Recipe: programmatic submit from an error boundary
Pair Userz with your existing error boundary so a "Report this" button just works:
import { useUserz } from '@userz-ai/react';
function ErrorFallback({ error }: { error: Error }) {
const userz = useUserz();
const report = async () => {
await userz.submit({
text: `Crash: ${error.message}\n\n${error.stack ?? ''}`,
severity: 'high',
withScreenshot: true,
});
};
return (
<div>
<p>Something went wrong.</p>
<button onClick={report}>Report this</button>
</div>
);
}Recipe: private mode with refresh-on-demand
Have your /me endpoint return a freshly-minted Userz token alongside the user, and feed it through getUserToken:
'use client';
import useSWR from 'swr';
import { UserzProvider } from '@userz-ai/react';
export function Userz({ children }: { children: React.ReactNode }) {
const { data } = useSWR('/me', fetcher);
return (
<UserzProvider
publicKey="pub_..."
getUserToken={() => data?.userzToken}
>
{children}
</UserzProvider>
);
}See @userz-ai/node for the matching mintUserToken server snippet.
SSR / Next.js
The widget is browser-only. Mount the provider in a client component:
// app/Userz.tsx
'use client';
import { UserzProvider } from '@userz-ai/react';
export function Userz({ children }: { children: React.ReactNode }) {
return (
<UserzProvider publicKey="pub_...">
{children}
</UserzProvider>
);
}
// app/layout.tsx, server component is fine here
import { Userz } from './Userz';
export default function Layout({ children }: { children: React.ReactNode }) {
return <html><body><Userz>{children}</Userz></body></html>;
}<Component /> in pages/_app.tsx with the same provider. No special config needed.<ScreenshotEditor>
Annotated screenshot editor, wraps tldraw to give the end-user a quick "circle the broken thing, drop an arrow, save" workflow before submitting feedback. The tldraw bundle is dynamic-imported on first mount, so apps that never open the editor don't pay for it.
import { useState } from 'react';
import { ScreenshotEditor, useUserz } from '@userz-ai/react';
function CapturedShot({ blob }: { blob: Blob }) {
const userz = useUserz();
const [open, setOpen] = useState(true);
if (!open) return null;
return (
<ScreenshotEditor
blob={blob}
onSave={async (annotated) => {
// Upload + submit using the widget's pipeline.
await userz.submit({
text: 'See annotation',
attachments: [
// Hand the annotated PNG into your own upload-url + submit flow,
// or pass it back through userz.captureScreenshot()/submit(),
// see @userz-ai/browser for the full SubmitInput shape.
],
});
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
);
}Props
| Name | Type | Description |
|---|---|---|
| blob* | Blob | PNG / JPEG blob to seed the canvas with (typically from userz.captureScreenshot()). |
| width | number | Override the canvas width. Defaults to the blob's natural pixel width. |
| height | number | Override the canvas height. Defaults to natural height. |
| onSave* | (annotated: Blob) => void | Promise<void> | Receives the annotated PNG. Plug into userz.submit() or your own upload flow. |
| onCancel | () => void | Optional dismiss handler, renders a Cancel button when supplied. |
| style | CSSProperties | Inline style override on the editor container. |
| className | string | CSS class on the container. |
| brandColor | string | Save-button background. Defaults to Userz mint #5ccfb4. Matches the bubble widget theming token. |
| brandForeground | string | Save-button text color. Defaults to #0c2a24. |
| backgroundColor | string | Cancel-button surface. Defaults to white. |
| textColor | string | Cancel-button text color. Defaults to #111. |
tldraw is declared as an optional peer dependency. If your app uses <ScreenshotEditor> you need to install it explicitly: pnpm add tldraw. If it's missing, the component renders an instructive fallback instead of throwing, apps that never open the editor pay no bundle cost.Testing
The provider creates real DOM and intercepts console.*. For unit tests, replace the provider with a stub so your components don't try to mount the widget under jsdom:
// test-utils/userz.tsx
import { createContext } from 'react';
import type { Userz } from '@userz-ai/browser';
import { vi } from 'vitest';
const MockCtx = createContext<Userz | null>(null);
export const mockUserz: Userz = {
open: vi.fn(), close: vi.fn(), toggle: vi.fn(),
setUser: vi.fn(), setMetadata: vi.fn(),
submit: vi.fn().mockResolvedValue({ id: 'feedback_test', status: 'received' }),
captureScreenshot: vi.fn(),
setTargetingEnabled: vi.fn(), registerTarget: vi.fn(() => () => {}),
destroy: vi.fn(),
};
export function MockUserzProvider({ children }: { children: React.ReactNode }) {
return <MockCtx.Provider value={mockUserz}>{children}</MockCtx.Provider>;
}Bundle impact
@userz-ai/react is ~3 KB gzip on top of @userz-ai/browser. The screenshot path (modern-screenshot) is dynamic-imported on first call to captureScreenshot() or submit({ withScreenshot: true }), so apps that never take a screenshot don't pay for it.