> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Widget by script tag

> The help centre and support chat in one line of HTML — no npm, no build, on any page.

**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:

```html theme={null}
<script
  src="https://cdn.userkit.dev/widget.js"
  data-publishable-key="uk_pk_live_…"
  defer
  crossorigin="anonymous"
></script>
```

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](/en/guides/components-script).

## What the page pays

One request, and the others only when somebody needs them:

| File                 | Size          | When                                                                                                    |
| -------------------- | ------------- | ------------------------------------------------------------------------------------------------------- |
| `widget.js`          | \~14 KB gzip  | always                                                                                                  |
| `v/vendor-<hash>.js` | \~115 KB gzip | on the first click, when a pointer reaches the button, or before either when a reply is already waiting |
| `w/app-<hash>.js`    | \~1 KB gzip   | with it — the panel's own entry                                                                         |
| `u/app-<hash>.js`    | \~16 KB gzip  | on the first component mount, if your page mounts one                                                   |

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**.

<Note>
  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.
</Note>

## 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](/en/guides/components-script), on the page that already
carries it.

```html theme={null}
<div data-userkit="Banner" data-slot="dashboard-top"></div>

<script
  src="https://cdn.userkit.dev/widget.js"
  data-publishable-key="uk_pk_live_…"
  defer
  crossorigin="anonymous"
></script>
```

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.

<Note>
  `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.
</Note>

## Configuration

All by attribute:

```html theme={null}
<script
  src="https://cdn.userkit.dev/widget.js"
  data-publishable-key="uk_pk_live_…"
  data-position="bottom-left"
  data-locale="en"
  data-theme="light"
  data-color-primary="#0b5cff"
  data-border-radius="0.75rem"
  data-font-family="Inter, sans-serif"
  data-articles="false"
  defer
  crossorigin="anonymous"
></script>
```

| Attribute              | Default           | What it does                                                                             |
| ---------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
| `data-publishable-key` | —                 | Required. `uk_pk_live_…` or `uk_pk_test_…`                                               |
| `data-position`        | `bottom-right`    | Corner for the button and panel                                                          |
| `data-locale`          | `pt-BR`           | `pt-BR` or `en`                                                                          |
| `data-theme`           | `system`          | `light`, `dark` or `system`. `light` turns dark mode off; `dark` forces it               |
| `data-color-primary`   | `#111827`         | Brand colour                                                                             |
| `data-border-radius`   | `0.5rem`          | Corner radius                                                                            |
| `data-font-family`     | inherits the page | The panel's typeface                                                                     |
| `data-articles`        | `true`            | Draw the published centre                                                                |
| `data-launcher`        | `true`            | Draw **our floating button**. `false` for a page that already has a "Support" of its own |
| `data-changelog`       | `true`            | **What's new** tab: the release notes this person has not read                           |
| `data-notifications`   | `true`            | **Notifications** tab: what your backend addressed to them                               |
| `data-feedback`        | `true`            | **Ideas** tab: the suggestions board, with their own votes                               |
| `data-checklist`       | `true`            | The onboarding checklist at the top of the panel, while a step is outstanding            |
| `data-surveys`         | `true`            | The survey the API decided to ask them, at the top of the panel                          |
| `data-whats-new`       | `false`           | Beyond the tab, opens unread notes as a **dialog**, by itself                            |
| `data-pageviews`       | —                 | `auto` emits `page_viewed` on load and on every navigation                               |
| `data-api-url`         | UserKit's API     | Only if you proxy                                                                        |

`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.

```html theme={null}
<!-- dark mode off, whatever the system says -->
<script src="https://cdn.userkit.dev/widget.js" data-theme="light" …></script>
```

And for the toggle that changes its mind with the page up:

```js theme={null}
UserKit.setTheme("dark");    // "light", "dark" or "system"
```

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`:

```html theme={null}
<script src="https://cdn.userkit.dev/widget.js" defer crossorigin="anonymous"></script>
<script>
  window.userkitSettings = { publishableKey: "uk_pk_live_…", position: "bottom-left" };
</script>
```

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`

```js theme={null}
UserKit.show();
UserKit.hide();
UserKit.toggle();
UserKit.showArticle("how-refunds-work");
UserKit.showNewMessage("My card was declined");
await UserKit.boot({ externalId: "usr_42", hash: "…" });

await UserKit.mount("#notice", "Banner", { slot: "dashboard-top" });
await UserKit.mountAll();  // sweep the page again, after swapping the DOM
UserKit.destroy();         // everything off the page: panel, button and components

const client = await UserKit.ready(); // the client, session already restored
UserKit.hideLauncher();               // hide ours; the panel still opens
UserKit.showLauncher();
UserKit.resetLauncher();              // hand the decision back to `data-launcher`
UserKit.setTheme("dark");             // "light", "dark" or "system", with the page up
UserKit.getState();                   // { open, unread, available, launcherVisible }
UserKit.subscribe((state) => {});     // follow all four
```

`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 `defer`red, 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.

```js theme={null}
const client = await UserKit.ready();
client?.track("pricing_viewed");
```

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:

```js theme={null}
UserKit.subscribe(({ available, unread, open, launcherVisible }) => {
  myButton.hidden = !available;   // nothing published, no chat: draw nothing
  myBadge.textContent = unread || "";
});
```

`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:

```js theme={null}
await UserKit.boot({ externalId: "usr_42", email: "grace@example.com", hash: "…" });
```

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.

<Warning>
  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.
</Warning>

## 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`:

```
script-src  'self' https://cdn.userkit.dev;
connect-src 'self' https://api.userkit.dev;
img-src     'self' https: data:;
```

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
