@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.
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.
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 isdeferred, 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.
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.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:
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:
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
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.