Skip to main content
The help centre and support chat in one line of HTML, on any page — Rails, WordPress, static HTML, a landing page edited in a CMS. No npm, no build:
Same help centre, same conversations, same publishable key. What changes is how it reaches the page. With a front-end build, <HelpWidget /> from @userkit/react is the same panel as code — with style control (unstyled), the inline arrangement, and the components inside your own. For the other components by script — sign-in, account, pricing — see Components by script.

What the page pays

One request, and the others only when somebody needs them: The first carries the client, the button, and the decision to draw it. The second is React and the panel. Somebody who never asks for help never downloads it; somebody with an unread reply gets it fetched for them, because the preview of that message is precisely the thing that cannot wait for a click — and somebody whose pointer reaches the button gets it fetched a moment before the click, which is what makes the panel open at once. The last is the components, and it is small because everything it shares with the panel — React, the client, the stylesheet — is the file above. A page with no [data-userkit] and no UserKit.mount() call never downloads it, and a page that uses both the widget and a component downloads React once.
Installed from npm the panel is in your bundle and loads with the page. By script it is fetched when needed — which makes this form lighter on most pages, not a lesser one.

Where it draws

In a shadow root at the end of <body>. The host element and the <script> tags we add to <head> are the only nodes of ours in your tree; everything drawn is inside the boundary, and your CSS does not enter it — not a * { } reset, not an !important on button. That is the one real difference from the npm package, where your CSS reaches the components on purpose. The button appears only when there is something behind it: published articles (public, no session needed) or the chat with a contact session. Neither, and the page is exactly as it was — a launcher in front of nothing is a button that opens an apology.

The components, on the same tag

widget.js is not only the button in the corner: it mounts the same drop-ins as Components by script, on the page that already carries it.
Same tag, same key, same client — no second <script> line to put a notice at the top of a page that already had support on it. The names, the data-* and the events are that guide’s, and data-auto="false" turns off automatic mounting here too.
userkit.js still exists, and the difference is exactly one thing: it has no floating button. A sign-in screen or a pricing page wants the components and nothing in the corner; a product that already installed the widget does not need it. Loading both on one page is harmless in either order — one client, one session, one set of page views, and the widget adds the launcher on top of whatever the other file already started. A second widget.js tag is ignored the same way.The one rule is that both tags carry the same data-publishable-key. One page is one environment: the session, the queued events and the stored keys all belong to it, so a second key cannot be served alongside the first. The tag that names a different one says so in the console and does nothing.

Configuration

All by attribute:
data-whats-new is the only one that ships off, and the reason is a real trade-off: the tab reaches whoever opens the panel, the dialog reaches everybody — at the cost of appearing over a page somebody opened to do something else. Turn it on when what you publish needs to be read rather than merely available. The two do not double up: being shown is what marks a note read, so whichever appears first empties the other.

Light, dark, or whatever they chose

With no data-theme, the button and the panel follow the reader’s prefers-color-scheme — right on most pages, and what we have done for a long time. It is not enough for an app that decides for itself: if its theme is a class on <html>, the OS can say dark while the toggle in the header says light, and the widget is then the one box on the screen in the wrong palette. CSS cannot fix that either — the panel lives in a shadow root the page’s stylesheet cannot reach. Hence an attribute.
And for the toggle that changes its mind with the page up:
It reaches the button, the panel and every mounted component, whichever of them exist yet. It is a redraw rather than a remount: somebody halfway through a message does not lose it in the switch. All five appear only when there is something to show: each needs a session and has a gate of its own — no published note means no What’s new tab, no outstanding survey means no survey, and a checklist whose every step is satisfied removes itself. Leave them on: an anonymous visitor on your landing page sees exactly the panel they saw before. They default on here and off in the npm package because a page that imports @userkit/react puts <ChangelogBadge /> and <NotificationBell /> in its own header, beside the avatar. This form has one button and one panel, and no header to hang a bell in. Only for a proxy | A page that would rather decide in code leaves the attributes off and calls init:
The object outranks the attribute when both speak about the same thing: written in code is a later decision than written in markup.

window.UserKit

show() works before the panel exists: it is fetched and then opens, which is what makes a “Help” button in your own menu work without you waiting on anything.

Waiting for the script

The tag is deferred, so your page has no way of knowing when it ran. ready() is the answer: a promise resolving with the client as soon as this origin’s session has been restored — or with null if there was no publishable key at all.
A promise rather than an event, on purpose: there is no moment to miss. Call it before, during, or long after start-up.

One button, not two

If your product already has a “Support” in its header, data-launcher="false" takes ours out of the corner. The panel is untouched — UserKit.show() opens it from your button, which is the whole reason to hide the launcher rather than not install the widget. For a decision made in code — “hide it while this modal is up” — use hideLauncher(). A command outranks the attribute from then on, and resetLauncher() is the only way back: an override that expired on its own would be our button reappearing over somebody’s checkout for reasons nobody could reconstruct. With the panel open the button is drawn either way, because there it is not a launcher — it is the panel’s own close.

Drawing your own button

subscribe publishes what the widget knows about itself, which is exactly what a button of your own needs in order to draw itself:
available is the same answer that decides whether our button appears — a button in front of nothing is a button that opens an apology. And unread is our badge’s own number, counted once: a line the person wrote themselves is never unread to them, and two independent counts would eventually disagree about that in front of them.

Identifying the person on the page

The script never creates a contact on its own. A tag that quietly started minting rows for landing-page traffic would be the worst possible default — the decision is yours, here as in the npm package:
Without it, the visitor sees the published help centre and nothing else, which is right for somebody who has not signed in. In a federated environment the hash is what your server signed; never mint that HMAC in the browser.
If your app already uses @userkit/js or @userkit/react, use the npm widget instead of this script. Two clients on one origin read the same session from localStorage and work, but they do not tell each other: signing out in one half leaves the other half signed in until the page reloads.

CSP

If your site sends a Content-Security-Policy, the loader and the files it fetches need script-src, the API needs connect-src, and the logo and avatars your environment serves need img-src:
The stylesheet needs no style-src of its own: inside the shadow root it is a constructed sheet (adoptedStyleSheets), which that directive does not govern. Two things still ride on style-src 'unsafe-inline' — a browser without constructed sheets, and the dark palette set in the panel, which the components render as a <style> inside their own tree. Without it the widget draws in light, and nothing else is lost. The crossorigin="anonymous" on the tag is what lets an error of ours reach your window.onerror with a stack instead of as "Script error.", and what lets the script load on a page that sends Cross-Origin-Embedder-Policy. We deliberately publish no SRI hash for widget.js: it is the file that changes so a fix reaches your page without you deploying anything, and a fixed hash is the opposite of that. The panel is immutable instead — its name carries the hash of its contents.

What only exists on npm

  • appearance.unstyled, and your own CSS over it
  • the inline arrangement, in the page’s flow
  • useHelpWidget() and the rest of the hooks
  • passing functions and objects as props in JSX — here that is UserKit.mount() or an addEventListener on the element