Connectly Webchat
A floating launcher and a chat panel you add to any page. A visitor clicks the launcher,
types, and the conversation lands in your Connectly inbox alongside every other channel.
There is no page to build and no UI to write.
What you need
One thing: a client key, from the webchat settings page in your Connectly dashboard.
Everything else — colours, the launcher's side and icon, the panel's size, the greeting —
is stored against that key and fetched at runtime, which is why a working integration is
two lines rather than a configuration file.
The key goes in your page's HTML where anyone can read it. That is fine — it names your
business, it does not authorise anything.
What it is not
It is not an iframe you size yourself, and it is not a component library. The widget lives
in a shadow root and is position: fixed to a viewport corner, so it does not inherit
your page's CSS and does not participate in your layout. Where you put the element in the
DOM does not matter.
It is also not something you should load twice — see "One integration per page" on the
next page. Two integrations means two launchers, two sessions and two sockets for one
visitor, and nothing errors.
Install
Two paths, decided by a single question: does your site have an npm install step?
- No build step — a CMS, a page builder, a static site, a tag manager, anywhere you can
paste HTML — use the script tag.
- Bundling your own frontend — React, Vue, Svelte, Solid, Angular — use the npm
package. It ships ES modules and a typed React wrapper.
The script tag
Paste this before the closing </body> tag. <head> also works — the script defers
itself until the page is ready either way.
<script defer src="https://webchat.connectly.ai/webchat2026.min.js"></script>
<connectly-webchat client-key="<your-client-key>"></connectly-webchat>
That is the whole integration. The launcher appears in the corner once the widget loads.
Place the element; do not call a function. <connectly-webchat> is a custom element,
and the browser upgrades whatever elements are already on the page the moment the definition
lands. Your markup may therefore sit before or after the script tag with the same result,
and this form works where a CMS, a theme or a tag manager lets you add markup but not run a
script.
If you do need the JS API, keep the call in a second tag carrying defer or
type="module", after the first. Both attributes hold execution until the document has
parsed and then run in document order, so window.ConnectlyWebchat exists by the time the
second tag runs; a plain inline <script> with neither attribute runs first, against a
global that is not there yet. The sturdier form — and the one to use when you cannot control
tag order — is the ready event, which you may subscribe to before the widget exists because
it bubbles to document:
<script defer src="https://webchat.connectly.ai/webchat2026.min.js"></script>
<connectly-webchat client-key="<your-client-key>"></connectly-webchat>
<script>
// A plain inline script is fine HERE: the listener is registered before the deferred
// bundle runs, which is exactly the ordering problem the ready event solves.
document.addEventListener('connectly-webchat:ready', (event) => {
console.log('webchat ready', event.detail);
});
</script>
open() is deliberately not what this example calls: it would pop the panel up for every
visitor on every page. Use the event to hook up analytics, and leave opening to the reader.
The event fires when the launcher renders, and is not replayed — register the listener
before the widget loads rather than after. It fires again if the widget is torn down and
re-initialized (a changed client-key, or an SPA remount), so it is once per launcher
rather than once per page.
The npm package
npm i git+https://github.com/connectlyai/webchat.git#v1.1.0
A git URL rather than a registry name: the package is distributed from its own GitHub
repository, which npm, pnpm and yarn all install from natively.
Always pin a tag. A git specifier resolves to a commit, so omit the fragment after #
and you get whatever is on the default branch at install time, which moves under you.
#semver:^1.1.0 also works, resolving against the repository's tags. If the install asks
for credentials, see "npm cannot install the git URL" under Troubleshooting.
ESM only, browser only. There is no CommonJS build, so reach it through import or a
bundler, never require().
React
'use client'; // Next.js App Router only — see "Server rendering" below.
import { ConnectlyWebchat } from '@connectly/webchat/react';
export function App() {
return <ConnectlyWebchat clientKey="<your-client-key>" />;
}
The component renders null. The widget is created imperatively and lives in <body>,
outside your tree; unmounting the component ends the session. react and react-dom are
optional peer dependencies, needed only for this entry point.
Any other framework, or none
import '@connectly/webchat';
That import is the integration: it defines the <connectly-webchat> custom element and
configures the origin. Then put the element on the page.
<connectly-webchat client-key="<your-client-key>"></connectly-webchat>
Svelte, Vue, Solid and Angular all render custom elements natively, so the import plus the
tag is everything — with two exceptions that break the build otherwise.
Vue needs isCustomElement, or the compiler treats the tag as a component it cannot
resolve, warns on every render, and emits nothing:
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'connectly-webchat' } } })
Angular needs schemas: [CUSTOM_ELEMENTS_SCHEMA] on the component, or the template
fails with NG0304.
Where you put the element in the DOM does not matter — it is a 0×0, pointer-events: none
mount point, and the launcher is fixed to the viewport corner. One exception: an ancestor
with a transform, filter or contain becomes the containing block for
position: fixed, so the element relocates itself to <body> — which breaks any framework
that thinks it still owns that node. Append to <body> yourself, or use the React wrapper.
Server rendering
Never import the package on the server. It defines a custom element extending
HTMLElement, which does not exist in a server runtime, so the import itself throws
ReferenceError: HTMLElement is not defined. That includes @connectly/webchat/react,
which imports the main entry.
Next.js, both routers — load it through next/dynamic with ssr: false, because even a
client component is prerendered during next build:
'use client'; // App Router only; also what allows `ssr: false` here.
import dynamic from 'next/dynamic';
const ConnectlyWebchat = dynamic(
() => import('@connectly/webchat/react').then((m) => m.ConnectlyWebchat),
{ ssr: false },
);
export function Webchat() {
return <ConnectlyWebchat clientKey="<your-client-key>" />;
}
Everywhere else — Remix, Nuxt, Astro, SvelteKit — the rule is the same: reach it from a
browser-only path (a dynamic import() in a mount or effect hook, a <ClientOnly>
wrapper, client:only, a plain module <script>), never a static import at the top of a
shared module.
One integration per page
Do not load the script tag and the npm package on the same page.
You get two launchers, two sessions and two sockets for one visitor, and the transcript
splits unpredictably between them. Nothing errors and nothing warns — multiple widgets on
one page is deliberately supported for other cases — so this is a bug you will only find
by looking at it.
Configure
Most of the appearance is not configured in code. Colours, the launcher's side and
icon, the panel's size and the greeting are stored against your client key in the
Connectly dashboard and fetched at runtime, so the common case needs no config at all —
change it in the dashboard and every page using that key follows, with no redeploy.
Everything below is for what the dashboard cannot cover: a per-page override, a value
driven by your own state, or a theme that has to match the page it sits on.
The precedence chain
Four layers, lowest to highest:
built-in defaults < dashboard settings < `config` < HTML attributes
They merge per group, not wholesale, so { launcher: { side: 'left' } } moves the
launcher without resetting its size, icon and offsets to defaults.
HTML attributes get the last word. An attribute is in the page's own markup, authored
by whoever owns the page; config is often set by a shared wrapper or component library
that the page author cannot edit. So <connectly-webchat side="left"> wins over
config={{ launcher: { side: 'right' } }}.
One consequence worth knowing: title is a global HTML attribute, so anything that sets
it on the element — a CMS, a framework, a tooltip helper — overrides config.panel.title.
See the title row under "Attributes" below. If you set the panel heading through
config, make sure nothing is also writing a title attribute.
init() is the one place the shorthands and config are reconciled for you: the
title/width/height options land as attributes, so when a single init() call passes
both a shorthand and a config for the same field, the shorthand yields to config and
logs a warning rather than beating it from the top tier.
<ConnectlyWebchat
clientKey="<your-client-key>"
config={{
theme: { accent: '#6c2bd9', colorScheme: 'auto' },
launcher: { side: 'left', label: 'Chat with us' },
panel: { width: 380, height: 560 },
}}
/>
Outside React, assigning config alone repaints nothing — it is a plain field, not an
attribute, so call reload() after:
const el = document.querySelector('connectly-webchat');
el.config = { theme: { accent: '#c026d3' } };
el.reload();
Each distinct config value costs a network round-trip. Values are compared
structurally, so an equal object is free — but bind config to a colour picker and you
send one request per intermediate value. Memoise it, apply on commit, or for a live
preview drive the --cwc-* custom properties instead (see "Theming with CSS" below).
config fields
config is a partial WidgetUi — the same shape the dashboard saves.
launcher — side, offsetX, offsetY, size, shape (circle | pill),
label, ariaLabel, icon ({ kind: 'default' | 'url' | 'none', url })
panel — width, height, radius
theme — accent, accentText, surface, surfaceAlt, text, textMuted,
border, bubbleVisitor, bubbleVisitorText, bubbleAgent, bubbleAgentText,
bubbleRadius, fontFamily, colorScheme (light | dark | auto)
zIndex
collision — bottomOffsetPx, maxBottomOffsetPx
mobile — breakpointPx, launcher (offsetX, offsetY, size), panel
(fullscreen, width, height, radius — 0 means "inherit the desktop value")
Visible in autocomplete but not yet acted on, so setting them does nothing:
behavior.autoOpen*, navigation.linkTarget, paths.*, panel.anchor,
launcher.hideOnMobile, collision.bottomOffsetSelectors,
collision.hideBehindModalDialog, position, bundleVersion.
Only the config stored for your business is range-checked. Values passed through
config or an attribute are merged in as given, so panel.width: 5000 is a 5000px panel.
Use the dashboard's ranges as your bounds. Note this cuts both ways with the precedence
order above: an out-of-range attribute is not clamped and cannot be corrected by
config.
And fontFamily names a face, it cannot load one: @font-face is ignored inside a
shadow root, so declare the face in your own stylesheet and name it here.
Attributes
Every attribute is optional except client-key.
| attribute |
meaning |
client-key |
Required. |
title |
The panel heading — not a tooltip, despite the attribute name. Load-bearing config: as the top precedence tier it overrides config.panel.title, so do not set it for tooltip purposes. |
width, height |
Panel size in bare pixels: width="380". "380px" parses to NaN and is ignored. Not range-checked — see "Only the config stored for your business is range-checked" above. On desktop height is capped at the viewport in CSS (min(…, 100dvh)); width is not, and the mobile panel height is not either. |
side |
left or right. |
offset-x, offset-y |
Launcher offset from its corner, in pixels. |
z-index, bottom-offset-px |
Stacking, and a flat lift above a cookie bar or a sticky cart. |
open |
Panel open. Value-aware: open and open="" are open, open="false" and an absent attribute are closed. In JSX just write open={isOpen}. |
log-level |
silent | error | warn | info | debug. Applies immediately, including after mount. |
React props
| prop |
reactive |
notes |
clientKey |
no |
Changing it remounts the widget: a different key is a different business, so session, room and transcript all reset. |
config |
yes |
Presentation overrides. An inline object literal is fine — see the round-trip warning above. |
open |
yes |
Omit it and the widget owns its own state; pass it and you own it, and a value you never update pins the panel there. Mounting with open={true} comes up showing the panel, with no closed-then-open flash. |
onOpenChange |
— |
(open: boolean) => void, fired for every open and close whoever caused it. On its own, without open, it is the read-only version: the widget stays in charge and you just observe. An inline arrow never re-mounts the widget. |
log-level is not a React prop: set it with setAttribute, or write it directly in JSX.
Theming with CSS
The --cwc-* custom properties are the supported theming API. Every resolved value is
written to a custom property on the host element, with no !important anywhere in that
path, so yours wins:
connectly-webchat {
--cwc-accent: #6c2bd9;
--cwc-launcher-size: 56px;
--cwc-panel-radius: 20px;
}
--cwc-* is desktop and --cwc-m-* is mobile, switched by a media query. Lengths take
units here (56px), unlike config, which takes plain numbers. This is also the layer
for anything that changes rapidly — it costs no network round-trip at all.
Reaching inside the shadow root is not supported. Class names and structure change
without notice; a stylesheet that depends on them will break on an ordinary widget update.
API
Which API you get depends on which integration you chose.
- The script tag publishes a page global,
window.ConnectlyWebchat.
- The npm package publishes none — the element itself is the API. Reach it from outside
your component tree with
document.querySelector('connectly-webchat').
window.ConnectlyWebchat — script tag only
The global is published when the widget bundle executes, so it exists in any tag that runs
after the bundle's — a defer or type="module" tag — and not in a plain inline script.
A plain inline script runs during parsing, and the bundle carries defer, so the inline
one runs first no matter where on the page you put it. Where you cannot rely on tag order,
register a connectly-webchat:ready listener on document (the event bubbles, and fires
when the launcher renders — it is not replayed for a listener added later) and use
the global from inside that, or place <connectly-webchat> declaratively and skip the
global entirely.
window.ConnectlyWebchat: {
init(options): Element | null;
destroy(): void;
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
isMounted(): boolean;
element(): Element | null;
version: string;
}
init(options) accepts:
| option |
meaning |
clientKey |
Required. |
title |
The panel heading. |
width, height |
Panel size. A bare number (380) or a px string ('380px') both work; any other unit is dropped with a console warning. |
logLevel |
silent | error | warn | info | debug. |
config |
Presentation overrides — the same object the npm package's config prop takes. |
open |
Open the panel as soon as it is ready, instead of waiting for a click. |
onMountedChange |
Called with true/false as the widget is added to / removed from the page. |
onOpenChange |
Called with true/false as the panel opens/closes. This is the one you want for a toggle button's label. |
Calling init() twice does not create a second widget. It returns the existing element
and applies any new options to it — except clientKey, which cannot be changed on a live
widget. Remove the old integration first if you need a different key.
isMounted() asks whether the widget is on the page at all (true from shortly after
init() until destroy()); isOpen() asks whether the panel is showing. isOpen() is
almost always the one you want, paired with onOpenChange.
<connectly-webchat> — both integrations
The element is registered by the script tag and by the package's import alike, and it is
the whole API on the npm path. See Configure for the attribute list.
const el = document.querySelector('connectly-webchat');
el.openPanel();
el.closePanel();
el.toggle();
el.open; // boolean, reflected to the attribute
el.config = { theme: { accent: '#c026d3' } };
el.reload(); // `config` is a plain field; assigning it repaints nothing on its own
In React, prefer the declarative open / onOpenChange props over reaching for the
element — the wrapper's onOpenChange is exactly the element's :open and :close
events on its own node.
Opening and closing the panel
The names differ by path — open() / close() exist only on the script tag's page
global, and the element's methods are openPanel() / closePanel() — but they are
equivalent: the global delegates to the element.
// React, declaratively:
<ConnectlyWebchat clientKey="<your-client-key>" open={isOpen} onOpenChange={setIsOpen} />
Events
Events are dispatched on the element and are bubbles / composed, so a page-level
listener works without holding a reference to the element:
document.addEventListener('connectly-webchat:open', (e) => {
console.log(e.detail);
});
| event |
when |
connectly-webchat:ready |
The launcher has rendered. Carries the widget version and configSource, which is default when the config had not arrived yet. |
connectly-webchat:open |
The panel opened, whoever caused it. |
connectly-webchat:close |
The panel closed, whoever caused it. |
connectly-webchat:error |
Something failed hard enough to stop the conversation. |
connectly-webchat:config-warning |
A config value was rejected or ignored. |
Payloads are on event.detail — see WebchatElementEventDetail in the package's types.
Version
WEBCHAT_ELEMENT_VERSION (exported from the package, carried on the ready event, and
available as window.ConnectlyWebchat.version on the script-tag path) identifies the
widget code. It is the first thing Connectly support will ask for. The npm package carries
its own version, on its own schedule; the two are not expected to match.
Troubleshooting
The widget fails quietly more often than it fails loudly, which is what makes these worth
naming.
The launcher appears, but chat does not work — or it looks unstyled
Check the client key first. An unrecognized or revoked key makes the widget fall back
to a default appearance and fail to start a conversation, without stopping the launcher
from rendering. So a launcher on the page proves the script loaded; it proves nothing
about the key.
Turn logging up (below) and look in the console for a warning naming the client key, or a
"widget config unavailable" / "unknown or inactive" message.
A key I just created looks broken for the first minute
Expected, not a bug. A newly issued key can take up to 60 seconds to become active
everywhere, and a revoked key can likewise take up to 60 seconds to actually stop working.
Wait a minute and reload before concluding the integration is wrong — and do not treat a
revocation as an incident response tool on its own.
npm cannot install the git URL
If HTTPS asks for credentials, the two variants that usually help:
# A developer machine set up with an SSH key rather than a token:
npm i git+ssh://git@github.com/connectlyai/webchat.git#v1.1.0
# CI, with a token in the environment and no interactive prompt available:
git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/"
Repository not found, or a credential prompt that will not take your password, almost
always means the token lacks read access to the repository — not that the tag is wrong.
Two launchers, or a transcript that splits in half
You have two integrations on one page — almost always the paste-in <script> and the
npm package, one of them added by a tag manager or a theme rather than by you. Nothing
errors, because multiple widgets on one page is a supported case. Search the rendered page
for both webchat2026.min.js and any bundled import of @connectly/webchat, and remove
one.
ReferenceError: HTMLElement is not defined at build or boot
The package was imported on the server. The import itself throws, before any component
renders, because the module defines a custom element extending HTMLElement. This includes
@connectly/webchat/react, and it includes a Next.js client component, which is still
prerendered during next build. The fix is a browser-only import path — see "Server
rendering" under Install.
Vue warns on every render, or Angular throws NG0304
Both mean the framework does not know <connectly-webchat> is a custom element.
- Vue — add
isCustomElement to the compiler options.
- Angular — add
CUSTOM_ELEMENTS_SCHEMA to the component's schemas.
Vue's failure is the nastier of the two: it warns rather than throwing, and emits no
element at all, so the page comes up looking like the script simply did not load.
The element moved itself into <body> and my framework broke
An ancestor with a transform, filter or contain becomes the containing block for
position: fixed, so the element relocates itself to <body> — and a framework that still
thinks it owns that node will throw on the next update. Append the element to <body>
yourself, or use the React wrapper, which expects it.
More logging
Raise the console verbosity of a production build:
<connectly-webchat client-key="<your-client-key>" log-level="debug"></connectly-webchat>
window.ConnectlyWebchat.init({ clientKey: '<your-client-key>', logLevel: 'debug' });
That separates "wrong key" from "wrong integration" from "network": you see whether the
config loaded, whether a session minted, and whether the socket connected.
Support
Escalate through your usual Connectly channel with the widget version
(window.ConnectlyWebchat.version, or the exported WEBCHAT_ELEMENT_VERSION) and your
client key, plus a console log captured at log-level="debug".