Skip to main content
It is the shortest way to put UserKit on a page, and it works in any stack: Rails, WordPress, Django, static HTML, a landing page somebody edits in a CMS. No npm, no build, no React on your side.
The element says which component; the script says whose page this is. There is nothing else to write. If your front end already has a build, the @userkit/react package ships the same components as code — with types, SSR and composition. Both draw the same thing; the difference is what you already have set up.
If your page already loads widget.js, it already mounts these components — same markup, same tag, no second <script> line. userkit.js is for pages that want the components and no floating button in the corner: a sign-in screen, a pricing page. What each one downloads is still only what the page uses — the help panel does not come along here, and the components app is fetched over there only on the first mount.Both on one page is fine in either order: whichever runs first builds the client, and the other adopts it — one session, one event queue, one set of page views. Give both tags the same data-publishable-key; one page is one environment, and a tag naming a second key says so in the console and does nothing.

The names are the package’s names

data-userkit="UserButton" mounts what <UserButton /> renders, and the data-* are its props. That is deliberate: the day you adopt a build, migrating is rewriting markup as JSX with the same names — no API to relearn. This table is the whole package. Every component @userkit/react exports is here, and a test in the repository is what keeps that true — what exists with a build exists without one, under the same name.

The five that draw YOUR markup

UserButton is handed an empty element and fills it. The five guards are the opposite: what is inside the element is exactly what they decide whether to show.
They are your nodes, not a copy of them: the script takes the element’s children out before mounting and hands the same nodes back inside the guard. An addEventListener your page had already attached to them still works. When the answer is no they leave the page rather than being hidden — which matters, because hidden markup is still readable by anyone who opens the inspector. And since <Flag>’s fallback is markup, and an attribute carries none, the HTML form has no such prop: write the old path into a second element with the flag inverted, or use UserKit.mount(), which takes real JavaScript.

Props as attributes

data-redirect-uri becomes redirectUri; data-customer="false" becomes customer: false; data-poll-interval-ms="30000" becomes a number. Those three conversions are all of them.
A date stays text: data-since="2026-01-01" arrives as a string, not as 2026.

When something happens

An attribute cannot hold a function, so the values a page wants to act on arrive as DOM events, from the element itself:
They bubble, so a listener on document catches them too. Signing in is not an event here because it is not something these components do: your own auth signs the person in, and UserKit.boot({ externalId, hash }) is the call that tells the page who they are.

When HTML is not enough

Props that do not fit in an attribute — a list, an object, a function — go through the global API, which exists as soon as the script loads:
data-auto="false" turns off automatic mounting, for a page that would rather decide when.

What mount() hands back

The components arrive over the network, so nothing is on screen when the call returns. The object it hands back answers both things you want from that: you can await it, and you can use it before.
update() replaces the props — it does not merge with the previous ones — and it is a re-render rather than a remount: a half-filled form survives it. It is also how a function passed as a prop stays current, because handlers are read on every draw instead of being frozen at the first one. unmount() called before the bundle arrives cancels the mount. That matters to anyone mounting from inside a framework: without it, a component that leaves the screen while the bundle is still in flight would strand a mount in a node the framework has already dropped.
An element handed to mount() is claimed immediately, so the automatic scan will not mount over it with its own data-*. Even so, code that mounts does not need to mark the element with data-userkit: the name is already in the call, and marking both is asking two halves to describe the same thing.

Knowing when the tag has loaded

The tag is deferred, so your page has no way of knowing when our file ran. Both loaders announce themselves when they install window.UserKit. Check for the object before you listen, and there is no moment to miss:
detail is the object itself. On a page carrying both files the event fires twice: widget.js widens the object userkit.js installed, and the second time is a real change to what it can do. { once: true } is right for a listener that wants mount, which both files have, and wrong for one that wants show.

React without the components in your bundle

A React app loading the script from the CDN gets both halves: the React is yours, and the components still arrive from the CDN on demand. The @userkit/react-mount package is the bridge — it draws nothing itself, and imports nothing at runtime but React.
It waits for the tag on its own, hands over new props as a re-render rather than a remount, reads callbacks on every render, and cancels the mount if the component leaves the screen before the bundle arrives.
If your app already bundles React and does not mind bundling the components too, @userkit/react is more direct — real components, with typed props. This package is for when you specifically want the heavy drawing to stay on the CDN.
The types for window.UserKit are published in @userkit/js/embed, which has no runtime half — install it as a devDependency and import with import type:

Mounting @userkit/react inside a shadow root

If you render the npm components into a shadow root of your own, wrap that tree in <MountTargetProvider> so the stylesheet lands inside the boundary and the floating halves portal inside it too — neither crosses a shadow boundary on its own:
It replaces setMountTarget({ styles, portal }), which was page-wide: one page can hold two trees — the help panel in its shadow root and a drop-in in your own layout — and a single setting meant the last mount decided for both, so a component mounted after the panel opened drew with no stylesheet and portalled into the panel’s boundary. Passing styles or portal to setMountTarget now logs a warning and does nothing; the function still sets the CSP nonce, which is genuinely page-wide.

Appearance

The widget’s tokens, on the script:
data-theme is light, dark or system (the default), and it is what answers an app that decides its own theme: without it the components follow the reader’s prefers-color-scheme, which disagrees with the toggle in your header on the night the OS is dark and the person chose light. It has two levels here, because the questions differ. On the script it is the page’s; on an element it is that one component’s, and the element wins — a <PricingTable /> inside a dark section of an otherwise light page is a decision made by whoever wrote that div:
With the page up, UserKit.setTheme("dark") (or "light", or "system") redraws every mounted component — without remounting, so a half-filled form survives the switch. An element with a data-theme of its own keeps it. The typeface inherits the page’s by default, and that is deliberate: unlike the floating widget, these do not live inside a shadow root. You place them inside your own layout, so they should look like part of it — your CSS reaches the .uk-* classes, which is exactly what the npm package offers anybody with a build.

What the page pays

A page that loads the script and marks nothing downloads no React. The hashed files carry a hash of their contents and are cached for a year; the first never changes address and is revalidated within the hour, so a fix reaches your page on a visitor’s next navigation without you deploying anything. The shared file is the same one the help widget uses, so a page carrying both downloads React once.

Content Security Policy

The components mount into your own DOM, and their stylesheet is moved onto a constructed sheet (document.adoptedStyleSheets) the moment it is written, so style-src needs nothing for it. A browser without constructed sheets, and the dark palette set in the panel — which the components render as a <style> in their own tree — still need style-src 'unsafe-inline'; without it the forms draw in light. The crossorigin="anonymous" on the tag is what lets an error of ours reach your window.onerror with a stack rather than as "Script error.".

What it never does

It creates no contact on its own. boot() is your decision here as it is in the package — a <script> that quietly started creating contacts from landing-page traffic would be the worst possible default.