@chatlychat/widget-element

中文文档
0.27.2发布于 2026/11/06MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 5064

The Chatly chat widget as a custom element (), plus the embed lifecycle framework wrappers share. Zero dependencies.

原始使用文档
文档目录14 节
  1. Why not just paste the snippet?
  2. Attributes
  3. lazy — deferring the bundle
  4. nonce — a page served with a strict CSP
  5. A malformed JSON attribute does not stop the chat
  6. Properties
  7. Events
  8. Inline mounting
  9. The main entry has no side effects
  10. One widget per page
  11. Composing with the snippet
  12. Serving the loader yourself
  13. License
  14. About this repository

@chatlychat/widget-element

The Chatly chat widget as a custom element. Zero dependencies.

pnpm add @chatlychat/widget-element
<script type="module">
  import '@chatlychat/widget-element/register';
</script>

<chatly-widget public-id="acme-web" api-url="https://api.chatlychat.com"></chatly-widget>

Works in Vue, Svelte, Angular, Lit, Astro, Alpine, htmx and plain HTML — anywhere a custom element works. For React, use @chatlychat/widget-react, which is built on this package's lifecycle.

Why not just paste the snippet?

If a <script> in your <head> is all you need, paste the snippet and stop reading. This package earns its keep when the widget's lifetime is tied to something your framework owns:

  • the chat should appear on some routes and not others, and your router unmounts the element;
  • the theme or the visitor's identity comes from component state;
  • your templates bind properties and listen for events, and you would rather write <chatly-widget :theme="theme" @chatly-ready="…"> than reach into window.

It also handles three things a hand-written integration usually does not: a widget left behind when its component unmounts, a re-boot caused by a DOM move, and a re-initialisation caused by a re-render that changed nothing.

Attributes

attributenotes
public-idRequired. The web channel's public ID.
api-urlDefaults to the host the loader was served from.
realtime-urlRarely needed; the API reports the right one.
loader-urlWhere loader.js is served from. Rarely set; see below.
lazyDefer the bundle until a visitor wants it; see below.
nonceYour page's CSP nonce, if you serve one; see below.
localeApplied with setLocale() once the widget is up.
css-overridesCSS injected into the widget's shadow root.
inlineBoolean. Mount the chat inside this element.
themeJSON.
behaviorJSON.
visitorJSON. null is "signed out" — see below.
copyJSON.
copy-by-localeJSON.
quick-linksJSON.

A visitor of null — visitor="null", or chat.visitor = null — means "signed out", and it is checked on mount rather than only on a change. A page that loads already signed out never makes the identity→nothing transition a reset() needs, so the previous account's external id otherwise stays in the browser (the widget keeps it at chatly:xid:<publicId>); on a shared device that is the last person's identity outliving a sign-out from another tab. A visitor that is simply ABSENT still changes nothing, deliberately: it cannot be told apart from "identity has not resolved yet", and resetting on that would drop a live conversation on every slow auth check. Ignored when identity-endpoint (or an identity provider) is set.

Attributes are live: change one and the element applies it. A changed visitor becomes an identify() and a changed locale a setLocale() — neither re-mounts the widget, and neither does a changed theme value: it is applied with setTheme(), which repaints an open panel in place with no network request. A change to anything else re-initialises it, which closes an open panel — and it only happens when a value really changed.

The one theme change that still re-initialises is a REMOVED top-level key (or undefined), because a merge cannot un-apply one. Nested objects such as darkTheme are sent whole, so dropping a key inside one is live. A value you had changed live and then removed keeps its last value until a page load.

lazy — deferring the bundle

widget.js is ~44KB gzipped and every visitor downloads it on every page view, whether or not they ever open the chat. lazy changes that:

<!-- a valueless attribute means "on", as everywhere else in HTML -->
<chatly-widget public-id="acme-web" lazy></chatly-widget>
<chatly-widget public-id="acme-web" lazy="interaction"></chatly-widget>
<chatly-widget public-id="acme-web" lazy="manual"></chatly-widget>
valuewhat happens
absent / falsethe bundle is fetched on page load, as it always has been
present / launcherthe loader paints a stand-in launcher matching your theme; using it fetches the bundle and opens the panel. A returning chatter's bundle is fetched immediately
interactionas above, plus a pre-warm on the visitor's first interaction with the page
manualnothing is painted or fetched until something calls Chatly('load') — returning chatters are not preloaded either

A returning chatter is a visitor this browser already holds a conversation for (chatly:vr:<publicId>). Every mode but manual fetches the bundle for them as the loader evaluates, with no click: the unread badge cannot be drawn without the bundle, so a reply that arrived while they were away would otherwise stay invisible. manual leaves that decision to you — Chatly.hasSession() is the same test, and loader.js answers it before the bundle exists:

<script>
  var a = document.createElement('script');
  a.async = 1;
  a.src = 'https://widget.chatlychat.com/loader.js';
  // AFTER loader.js has evaluated: it is what defines `hasSession`, and the
  // tag is async, so asking any earlier gets `undefined`.
  a.onload = function () {
    if (window.Chatly.hasSession()) window.Chatly('load');
  };
  document.head.appendChild(a);
</script>

Anything else — lazy="eventually" — loads eagerly, which is today's behaviour and the safe direction, and warns by name. A typo that silently disabled lazy loading would look exactly like a lazy mode that does not work.

The stand-in launcher is drawn by this package. It is a real <button> in a shadow root, so it is keyboard reachable and your page's CSS cannot reshape it, and its click rides the same queue the snippet has always used — so the panel opens as soon as the bundle replays it. A visitor is never left with no way to start a chat.

It defers the REQUEST, not just the bundle

Since 0.20.0, lazy other than 'off' means no request to any Chatly host until the visitor reaches for the chat. Nothing is contacted on page load: not widget.js, and not loader.js either.

That is a change, and it is worth saying why it needed one. The lazy decision used to live inside loader.js, so the script that decided whether to fetch anything had itself to be fetched first — on every page view, in every mode, 'manual' included. The request carries the visitor's IP address, user agent and referrer, so a page that had set lazy="manual" still told us a visitor was there before they touched anything. The triggers are part of this package now, which is to say they run in your bundle, so there is nothing to fetch before the decision is made.

lazy="off" (the default, and any unset lazy) is unchanged and eager: it means "fetch on page load", and deferring it would move when the launcher appears on pages that never asked for that.

If you paste the <script> snippet instead of using this package, the page-load request is structural and cannot be deferred — lazy cannot run until loader.js has arrived. Mirroring our assets onto your own domain is the other way to avoid it; see the public docs for /version.json, the immutable /v/<build>/ paths and their SRI hashes.

Two costs worth knowing. A track() call made in lazy mode waits in the queue until something loads the bundle, so a visitor who leaves the page first loses the event. And lazy is read ONCE, at boot: changing the attribute afterwards warns rather than taking effect.

With inline there is no launcher to stand in for, so the trigger is the element approaching the viewport instead. prefers-reduced-data and the browser's data saver suppress the interaction pre-warm; neither ever turns lazy mode on by itself.

nonce — a page served with a strict CSP

If your page is served script-src 'nonce-…' with no 'unsafe-inline', give the element that response's nonce:

<chatly-widget public-id="acme-web" nonce="r4nd0m-per-response"></chatly-widget>

The element puts it on the loader.js script it injects, and publishes it as window.Chatly.nonce so that widget.js, the deferred chunks and the panel's own stylesheet carry it too. Without it, a nonce-only policy blocks the loader and the only sign is a CSP violation in your console.

Two things to get right, and both are about where the value comes from rather than about this attribute.

A nonce is per-response. Mint a fresh one for every page render and never hardcode one: a nonce that repeats is no better than 'unsafe-inline', since anyone who can read one page's source can then inject a script the next page will run. Nothing here caches it — but whatever renders your HTML might, so it has to come from the request.

Read it from the property, not the attribute, if you ever read it back. HTML hides nonces: on a page with a header-delivered CSP the browser blanks the nonce content attribute as soon as an element is connected and keeps the value only in the element's internal slot, which element.nonce exposes. That applies to <chatly-widget> as much as to a <script>, so this element reads its own nonce through the property — a getAttribute('nonce') read works in every test and returns nothing on a real page.

If your policy allows the widget's origin by host instead, leave the attribute off. Nothing is stamped and the embed injects exactly what it always has.

A malformed JSON attribute does not stop the chat

It is dropped, console.error says which attribute and why, and the widget starts without it.

That is the opposite of what the widget does with a mountTo selector that matches nothing, and deliberately so: a failed mountTo would put an unexpected floating bubble on a page built to embed the chat, which reads as a bug and is easy to miss, whereas a failed theme leaves a working chat in the default colours and a named error in the console. Losing support chat on your site over a trailing comma is the worse trade.

Properties

Every JSON attribute is also a property, and the property wins:

const chat = document.querySelector('chatly-widget');
chat.theme = { primaryColor: '#4f46e5', colorScheme: 'auto' };
chat.visitor = { externalId: 'u_42', email: '[email protected]', userHash };
chat.config = { behavior: { tabs: ['home', 'messages'] } }; // the whole object

Setting a property before the element is defined or upgraded works too, which matters because every property-binding template engine can produce that ordering.

chat.controls is the widget's post-boot method object, so a reference to the element is enough to drive the chat. It is also exported on its own as controls. Twenty-one methods — the widget's ChatlyApi minus init and destroy (the element owns the lifecycle), minus on/off, whose job subscribe() does below, and minus mount/unmount, which add a second view outside the reference-counted lifecycle this package owns:

open, close, toggle, identify, setUser, setCustomAttributes, deleteCustomAttribute, setLabel, removeLabel, setLocale, track, registerPushToken, reset, setStorageConsent, setOffset, setTheme, getUnreadCount, hasSession, startConversation, setContext, reportAction.

Two of those answer rather than act, and both are safe before the widget has mounted: getUnreadCount() is 0 and hasSession() is false until there is something to ask. Pair getUnreadCount() with the unread event rather than polling it.

The other nineteen ACT, and a call made before a widget mounts is held rather than dropped (0.21.0): it is queued and replayed as soon as one mounts, so a host-page button that renders before <chatly-widget> keeps its first click. One console warning says the call was queued — a page that never mounts a widget still gets nothing.

load is NOT on controls, deliberately: it is the loader's verb, not the widget's, and controls mirrors ChatlyApi exactly. Use prefetchWidget(), exported alongside it.

hasSession() is answerable earlier than the rest, so a lazy="manual" page can use it to decide — and since 0.20.0 it is answerable immediately: startWidget installs it on the shim synchronously, from the same stored-thread key loader.js and the bundle read, so a read in the mount commit is fine. It used to be the loader's alone, and the loader tag is async, so that read got false. A plain snippet with no element on the page still has to wait for the loader — see the onload example above.

init and destroy are deliberately absent: the element owns the lifecycle, and calling them behind its back leaves it believing a widget is mounted that is not.

Events

The element re-dispatches all twelve of the widget's events as a DOM CustomEvent, with the widget's payload on detail. They bubble and are composed, so a listener on an ancestor hears them.

chatly-ready, chatly-open, chatly-close, chatly-message, chatly-message-ack, chatly-message-received, chatly-unread, chatly-conversation, chatly-identified, chatly-reset, chatly-action, chatly-error — exported as ELEMENT_EVENTS.

A dot in the widget's own event name becomes a dash, so message.ack is chatly-message-ack. Same reason the prefix is chatly- and not chatly:: Vue reads @chatly-message.ack as the event chatly-message with a modifier called ack, and the listener never fires.

chatly-ready and chatly-unread are replayed to a late subscriber by the widget, and the element subscribes in connectedCallback — so the replay is dispatched then. A template binding (@chatly-unread) is attached before that and sees it; an addEventListener call made after the element is connected can miss it, and should read controls.getUnreadCount() once for its initial value.

<chatly-widget public-id="acme-web"></chatly-widget>
<script type="module">
  import '@chatlychat/widget-element/register';
  document.querySelector('chatly-widget').addEventListener('chatly-message', (e) => {
    console.log('reply from', e.detail.authorType);
  });
</script>

Hyphenated, not chatly:message: a colon is legal in an event name but not in a Vue, Angular or Svelte template binding.

Until 0.4.0 three of them had no chatly-* form at all — message.ack, message.received and unread were named by ChatlyEventName, so subscribe() reached them, and the element forwarded nine. If you worked around that, the workaround still works and is no longer needed.

subscribe() is the other way in, and the one to use from code that is not a listener on the element — it goes through Chatly.on directly:

import { subscribe } from '@chatlychat/widget-element';

const off = subscribe('unread', (detail) => paint(detail.count));

It hands back the unsubscribe, which matters: Chatly.off only removes a handler it can match by identity, so a consumer who builds a fresh closure for each call leaks a subscriber on every mount. The plain window mirror (chatly:unread) carries all twelve too, if you would rather not import anything.

To wire all twelve without copying a list out of this README, iterate the map the forwarding is derived from — EVENT_HANDLERS (widget event → handler name) and WIDGET_EVENT_NAMES, both exported since 0.4.0. They are the single source the element, the React wrapper and the type checker all read, so a list built from them cannot disagree with what is actually forwarded.

Inline mounting

<chatly-widget public-id="acme-web" inline style="height: 600px"></chatly-widget>

The chat fills the element instead of floating. No launcher, no corner offset, and close() warns instead of working — hide or remove the element instead.

The element sets display: block on itself in this mode, because a custom element is display: inline by default, which collapses to a zero-height box and looks exactly like the widget failing to load. It also sets a 420px min-height unless you set one.

The main entry has no side effects

Importing @chatlychat/widget-element defines nothing and touches no DOM. @chatlychat/widget-element/register is the opt-in that defines <chatly-widget>, and defineChatlyWidget('my-tag') is there if you need a different tag name. Calling either twice is safe.

This split is why @chatlychat/widget-react can depend on this package for the embed lifecycle without registering an element its consumers never asked for, and why nothing here crashes a server-side bundle — a top-level class X extends HTMLElement would throw HTMLElement is not defined the moment a Next.js server component imported so much as a type from here.

One widget per page

window.Chatly is a singleton. Two connected <chatly-widget> elements with different attributes is a misconfiguration: the first owns the configuration, the second's attributes are ignored (its events still fire), and a console warning says so. Two with the same configuration is harmless — which is what makes an element inside a template that re-renders safe.

Moving the element in the DOM, or removing and re-adding it in the same task, does not re-boot the widget. A disconnectedCallback is not necessarily an unmount: appendChild elsewhere fires disconnect then connect, and so does a framework re-keying a list.

Composing with the snippet

If the page already has the snippet, this package does not fetch a second loader — it queues its init onto the shim the snippet installed and lets that loader replay it. The element's configuration wins. You do not need both, but having both is not broken.

Serving the loader yourself

loader-url defaults to https://widget.chatlychat.com/loader.js, which is the right answer for almost everyone. Set it if you serve loader.js off your own CDN, or to aim the element at another Chatly environment — GET /v1/channels/:id returns the right value for the deployment you asked as embed.loaderUrl, so read it from there rather than typing a host. The loader resolves the bundle from its own URL, so a copy you serve needs widget.js and the chunks it fetches sitting beside it.

The same response lists every origin a running widget connects to — connectOrigins: the API host, and the realtime host under both https: and wss: — and a CSP built from the script host alone lets the launcher paint and then blocks every request it makes.

License

MIT — see LICENSE.


About this repository

This is a mirror. @chatlychat/widget-element is built from a private monorepo where the rest of the platform, its migrations and its contract checks live, so a pull request here cannot be merged directly — but a patch is still the fastest way to get something fixed and we will port it with attribution.

What you can do here: read the source, run the tests, open an issue, or open a PR and we will treat the diff as the proposal.

Not mirrored: src/icon-names-parity.spec.ts — specs that assert a type restated here still matches the monorepo's original, which is only a meaningful claim inside it. They keep running there.

Issues: https://github.com/chatlychat/chatly-widget-element/issues · Docs: https://chatlychat.com/docs

版本与发布记录

@chatlychat/widget-element 共发布 34 个版本,首次发布于 2026/10/13;过去 12 个月发布了 34 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.27.2latest2026/11/063 天前
0.27.12026/11/045 天前
0.27.02026/11/045 天前
0.26.02026/10/2812 天前
0.25.02026/10/2812 天前
0.24.02026/10/2713 天前

关于 @chatlychat/widget-element 的常见问题

@chatlychat/widget-element 是什么?

The Chatly chat widget as a custom element (), plus the embed lifecycle framework wrappers share. Zero dependencies.

如何安装 @chatlychat/widget-element?

在项目目录运行 npm install @chatlychat/widget-element,或使用 pnpm add / yarn add / bun add。

@chatlychat/widget-element 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@chatlychat/widget-element 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@chatlychat/widget-element 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@chatlychat/widget-element 可以商用吗?

@chatlychat/widget-element 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@chatlychat/widget-element 的下载量有多少?

根据 npm 官方下载统计,@chatlychat/widget-element 在 2026/10/10 – 2026/11/08 这 30 天内被下载 5,064 次,日均约 169 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@chatlychat/widget-element 还在维护吗?

最新版本 0.27.2 发布于 2026/11/06(3 天前);至今共发布 34 个版本,其中过去 12 个月发布了 34 个;首次发布于 2026/10/13。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。