Widget Integration
The UserTold.ai widget embeds directly in your product. Participants never leave your site — they see a floating button, answer the intake, and start an interview, all in-context.
Basic Embed
Add one line to your page:
<script
async
src="https://usertold.ai/v1/widget.js"
data-project-key="ut_pub_YOUR_KEY"
data-screener-id="your-screener-handle"
></script>
That's it. A floating button appears in the bottom-right corner shortly after your page finishes loading.
widget.js is a tiny (~0.6KB) self-deferring loader: it waits for your page's load event, then fetches the real widget bundle (widget-core.js) at background priority — the widget never competes with your page's own content for bandwidth or main-thread time. There is nothing extra to configure; performance-safe loading is the default. See Performance for the details.
The public widget assets are served from https://usertold.ai. Keep that separate from the dashboard origin (https://app.usertold.ai) and any API endpoints your integration may call.
Performance
The widget is designed to stay off your page's critical render path, with no integrator-side work:
- Two-stage loading — the
widget.jsyou embed is a ~0.6KB loader. The real bundle (widget-core.js) is fetched only afterwindow.load, scheduled at background priority (scheduler.postTaskwhere available,requestIdleCallbackwith a 3s timeout elsewhere,setTimeoutas the floor for Safari). Your Largest Contentful Paint (LCP) never waits on UserTold. - Connection warm-up — the loader adds a
preconnecthint forusertold.aiwhen scheduling begins, so the deferred bundle and API fetches skip DNS/TLS setup (typically 100–500ms) without holding a socket open during your page's startup. - Data saver — when a visitor has data-saver enabled (
navigator.connection.saveData), the loader skips loading the widget entirely out of respect for their choice. - Data fetching — when the widget boots with a
screenerIdorstudyId, it fetches that configuration from the UserTold API before showing the launcher. This request also happens after your page is fully loaded. - Failure isolation — if the widget bundle or API is slow or unreachable, the launcher simply doesn't appear. Your page is never blocked, and no broken UI is shown while the widget is pending.
- Calling the API before the bundle loads — the loader initializes
window.UserToldas a command queue with callable method stubs. Both styles work immediately and replay in order on boot:
<script>
// Either style, any time after the embed tag:
UserTold.identify('user_123', { plan: 'pro' });
window.UserTold.push(['identify', 'user_123', { plan: 'pro' }]);
</script>
Configuration
Via Script Tag
<script
async
src="https://usertold.ai/v1/widget.js"
data-project-key="ut_pub_YOUR_KEY"
data-screener-id="your-screener-handle"
data-brand-color="#3b82f6"
data-position="bottom-left"
></script>
Via JavaScript
Load the script without async so UserTold exists immediately (it's the ~0.6KB loader — the heavy bundle still loads deferred), or guard with window.UserTold = window.UserTold || [] and use the queue style:
<script src="https://usertold.ai/v1/widget.js"></script>
<script>
UserTold.init({
projectKey: 'ut_pub_YOUR_KEY',
screenerId: 'your-screener-handle',
brandColor: '#3b82f6',
position: 'bottom-left',
launcherText: 'Share your thoughts',
});
</script>
Via Global Settings
<script>
window.UserToldSettings = {
projectKey: 'ut_pub_YOUR_KEY',
screenerId: 'your-screener-handle',
brandColor: '#3b82f6',
};
</script>
<script src="https://usertold.ai/v1/widget.js"></script>
Configuration priority (highest to lowest): init() arguments > window.UserToldSettings > script tag data-* attributes > defaults.
Options
| Option | Type | Default | Description |
|---|---|---|---|
projectKey | string | — | Required. Public project key (ut_pub_...) |
screenerId | string | — | Intake reference (ID or handle) for qualification flow |
studyId | string | — | Study reference (ID or handle) for direct interview (skips intake) |
brandColor | string | oklch(67% 0.135 47) (terracotta) | Any valid CSS color — hex, named, or oklch(...). Hover / light / ink shades are derived via color-mix(in oklab, ...). |
theme.light.bg / theme.light.fg | string | — | Override widget surface / text color in light mode. Leaving undefined keeps the default token. |
theme.dark.bg / theme.dark.fg | string | — | Same, for dark mode. |
position | string | bottom-right | Widget position: bottom-right or bottom-left |
defaultDocked | 'left' | 'right' | false | false | Collapse the launcher into a side dock on initial render. |
launcherText | string | Share feedback | Text on the floating button |
voiceAssistant | boolean | false | Enable OpenAI Realtime voice for talk segments. Requires the project's OpenAI key. |
voice | string | marin | Voice name for the AI interviewer. |
voiceMode | 'auto' | 'auto' | Turn-detection mode (server VAD). push-to-talk is reserved but not yet implemented. |
onComplete | function | — | Callback fired when the widget is fully closed/destroyed. |
Either screenerId or studyId is required. Use screenerId when you want to qualify participants first via an intake. Use studyId to go straight to the interview.
Participant Flow
- Launcher — Floating button with your brand color and text. Non-intrusive.
- Intake — Multi-step qualification form. One question per screen with progress dots. Includes consent checkbox on the last step.
- Permissions — Requests microphone access everywhere. On desktop browsers with screen capture support, it also requests screen sharing. On mobile or unsupported devices, interviews continue with audio and in-page events only.
- Interview — Evidence-first conversation. The study script moves through
talk, scriptedspeak, and silentobservesegments so participants can complete tasks and then debrief from captured context. - Complete — Thank-you message with optional incentive information.
If a participant doesn't qualify, they see a polite disqualification message and can close the widget.
Programmatic Control
window.UserTold exposes the following methods. Before the core bundle boots, calls are queued (the loader installs method stubs that record ['method', ...args] tuples) and replay in order on boot (see Performance):
| Method | What it does |
|---|---|
init(config?) | Initialize the widget. Merges config over window.UserToldSettings and script data-* attributes. |
activate(studyId, options?) | Switch the launcher to a different active study. Safe to call on SPA route changes — skips if an interview is in progress. options.defaultDocked lets you pin the launcher to 'left', 'right', or false. |
deactivate() | Tear down the current widget instance without resetting global config. Returns false if an active interview is preserved instead of being torn down. |
startInterview(options?) | Open the interview directly. options.screenerId or options.studyId overrides whatever was configured. |
identify(userId, traits?) | Attach a host-side user identifier and free-form traits to the current widget interview for later correlation. |
on(event, handler) / off(event, handler) | Subscribe / unsubscribe to widget lifecycle events. |
widget(config) | Legacy: create a fully-configured WidgetInstance with open(), close(), destroy(), hasActiveSession(), dockToSide(side), undockFromSide(). Pre-boot calls are queued and the widget renders on boot, but the return value is undefined until the core has loaded — call it from on('ready', ...) or after load if you need the instance. |
initialized | Boolean — true once the widget has finished bootstrapping. |
Start an Interview Directly
UserTold.startInterview({
screenerId: 'your-screener-handle'
});
Switch Studies on Route Change
// In an SPA route handler, after navigating to /checkout
UserTold.activate('checkout-q1', { defaultDocked: 'right' });
Deep Links via URL Parameters
Direct participants to a specific intake or study by adding URL parameters:
https://your-site.com?ut_start=beta-users
https://your-site.com?ut_study=checkout-q1
The widget auto-opens on page load when these parameters are present.
Identify Participants
UserTold.identify('user_123', {
email: 'user@example.com',
name: 'Jane Doe',
plan: 'pro',
// Any custom traits
});
Styling
The widget renders inside a Shadow DOM to avoid CSS conflicts with your site. It uses fixed positioning with a high z-index to float above your content.
Customizable via configuration:
- Brand color — applied to buttons, progress indicators, and accents
- Position — bottom-right or bottom-left corner
- Launcher text — button label
Technical Details
The widget ships in two stages:
- Loader (
widget.js, ~0.6KB) — the embed entrypoint. Sets up thewindow.UserToldcommand queue and schedules the core bundle afterwindow.loadat background priority (see Performance) - Core (
widget-core.js) — reads configuration from your embed tag (orwindow.UserToldSettings/ queuedinit), discovers the intake or study, and renders the launcher; the interview UI stays dormant until the participant clicks the launcher
This keeps the integration one line while keeping the initial page impact near zero.
Troubleshooting
| Issue | Solution |
|---|---|
| Widget doesn't appear | Check that projectKey is set and valid (ut_pub_...). Ensure screenerId or studyId is configured. |
| Microphone permission denied | The participant must grant microphone access. Check browser permission settings. |
| Screen sharing is unavailable on mobile | The widget automatically falls back to microphone and in-page event capture on mobile and other devices without usable screen capture. |
| "No active study linked" | The intake must have a linked study with status active. |
| Widget styles look broken | Check for Content Security Policy (CSP) rules that might block inline styles or the widget script. |
| Interview hangs | Check browser console for WebSocket errors. Verify the study is active and API keys are configured. |
See also
- Quickstart — end-to-end setup including embed step
- Studies — configure what the widget asks