@askelo/react
v0.1.1
Published
React components and hooks for the Askelo support widget.
Downloads
286
Maintainers
Readme
@askelo/react
The Askelo support widget, as React components and a hook.
npm install @askelo/reactQuick start
Render it once, near the root of your app. Nothing else is required — the launcher, the panel and everything they look like come from your widget's settings in the dashboard.
import { AskeloWidget } from "@askelo/react";
export default function App() {
return (
<>
<YourApp />
<AskeloWidget widgetId="your-widget-id" />
</>
);
}Your widget id is in Channels → Widget in the dashboard. Add the origin
your app runs on to that widget's allow-list, or the widget will refuse to
start (error.code === "bootstrap_failed").
Controlling the widget
Wrap the part of your app that needs control in <AskeloProvider> and use
useAskelo():
import { AskeloProvider, useAskelo } from "@askelo/react";
function SupportLink() {
const { open, unreadCount, status } = useAskelo();
return (
<button onClick={open} disabled={status !== "ready"}>
Support{unreadCount > 0 ? ` (${unreadCount})` : ""}
</button>
);
}
export default function App() {
return (
<AskeloProvider widgetId="your-widget-id">
<YourApp />
<SupportLink />
</AskeloProvider>
);
}<AskeloWidget> is <AskeloProvider> with no children, so moving from one to
the other is a rename.
Your own launcher
Pass hideLauncher and the floating bubble never appears — along with the
greeting bubble and unread badge that hang off it. Your trigger becomes the only
way in, and the unreadCount from useAskelo() is how you badge it.
<AskeloProvider widgetId="your-widget-id" hideLauncher>
<YourApp />
</AskeloProvider><AskeloLauncher> is an unstyled <button> that already handles the parts that
are easy to get wrong — aria-expanded, aria-haspopup, an accessible name
that announces unread replies without overriding your visible text, and a
disabled state while the widget loads:
<AskeloLauncher className="your-button-styles">
{({ unreadCount }) => <>Support {unreadCount > 0 && <Badge>{unreadCount}</Badge>}</>}
</AskeloLauncher>Toggling hideLauncher after mount shows or hides the launcher in place. It
never reloads the widget, so it will not interrupt a conversation.
Styling
Two surfaces, customized two ways, because they live in two different places.
The chrome — CSS on your own page
The launcher, the floating card and the greeting bubble are rendered in your
DOM, so plain CSS reaches them. Set these custom properties anywhere they
cascade — :root, a wrapper, a media query, your dark-mode class:
:root {
--askelo-launcher-size: 72px;
--askelo-launcher-offset-y: 120px; /* clear of your cookie banner */
--askelo-panel-radius: 4px;
--askelo-z-index: 500; /* put your own modal above the widget */
}| Property | Default | What it does |
| --- | --- | --- |
| --askelo-launcher-size | 60px (54px under 768px) | Launcher width and height |
| --askelo-launcher-offset-x | 24px (20px under 768px) | Distance from the left/right edge |
| --askelo-launcher-offset-y | 24px (20px under 768px) | Distance from the top/bottom edge |
| --askelo-launcher-radius | 50% | Launcher corner radius |
| --askelo-launcher-background | gradient from your brand colour | Launcher fill |
| --askelo-launcher-shadow | two-layer soft shadow | Launcher shadow |
| --askelo-panel-width | 420px (fills the screen under 768px) | Floating panel width |
| --askelo-panel-height | 640px (fills the screen under 768px) | Floating panel height |
| --askelo-panel-offset | 104px | Gap between the panel and the viewport edge |
| --askelo-panel-radius | 20px (16px under 768px) | Panel corner radius |
| --askelo-panel-shadow | two-layer deep shadow | Panel shadow |
| --askelo-greeting-width | 320px | Greeting bubble width |
| --askelo-greeting-background | white | Greeting bubble fill |
| --askelo-greeting-radius | 16px | Greeting bubble corner radius |
| --askelo-greeting-shadow | soft shadow + hairline | Greeting bubble shadow |
| --askelo-z-index | 2147483000 | Base of the widget's stack — launcher +2, panel +1, greeting -1 |
Set a property once and it applies at every breakpoint: the responsive defaults only apply when you have not set one. A launcher you sized at 72px stays 72px on a phone, because that is what you asked for.
The embedded chat's own box is yours entirely — className and style on
<AskeloChat> are your element:
<AskeloChat widgetId="…" className="h-[70vh] rounded-2xl border shadow-sm" />The panel — the theme prop
The panel is a cross-origin iframe, so no CSS of yours reaches inside it. Three values cross the boundary instead:
<AskeloProvider
widgetId="your-widget-id"
theme={{
accent: "#ff0055",
fontFamily: '"Söhne", system-ui, sans-serif',
colorScheme: resolvedTheme, // "light" | "dark" | "auto"
}}
>
<YourApp />
</AskeloProvider>| Key | Default | Notes |
| --- | --- | --- |
| accent | your dashboard setting | Overrides it for this page only |
| fontFamily | the platform UI stack | Nothing is downloaded — name a face your app already loads |
| colorScheme | "auto" | "light" / "dark" force it; "auto" follows the visitor's OS |
Changing the prop re-themes in place — it never reloads the widget and never closes an open conversation, which matters because the moment it changes is usually the moment someone hit your theme toggle mid-chat. An inline object is fine: only its contents are compared.
Wiring it to next-themes is the common case:
const { resolvedTheme } = useTheme();
return (
<AskeloProvider widgetId="…" theme={{ colorScheme: resolvedTheme === "dark" ? "dark" : "light" }}>
{children}
</AskeloProvider>
);The same three values can come from CSS instead, which is what a plain
<script> tag install uses — they are read off your root element when the
widget loads. CSS is read once, at load; the prop applies at any time and
wins over it.
The two accents are separate on purpose.
--askelo-accentthemes the panel's interior;--askelo-launcher-backgroundthemes the launcher. Setting your brand colour in the dashboard drives both, which is what nearly everyone wants — reach for these only when one surface has to differ from the other on a particular page.
What is not themeable, and why
Spacing, radii and per-element colours inside the panel are deliberately not exposed. Everything that crosses the iframe boundary becomes an API we have to keep working, and the panel's interior already sits inside a box whose outside you control completely. If you need something specific there, ask — that is a better conversation than a token nobody can ever change.
A chat on your own page
Sometimes the bubble in the corner is the wrong shape — a dedicated support
page, a help sidebar, an in-app Help tab. <AskeloChat> draws the same widget
as a block on your page:
import { AskeloChat } from "@askelo/react";
export default function SupportPage() {
return (
<main>
<h1>Support</h1>
<AskeloChat widgetId="your-widget-id" style={{ height: 640 }} />
</main>
);
}Give it a height. The chat fills its container and brings no size of its
own, so a container without one renders a box of nothing. style={{ height }}
or a className both work — if you pass a className, sizing is entirely
yours and no default is applied.
It is the same widget and the same conversation. Someone who asks a question on your support page and later opens the launcher from your pricing page is in one conversation, and the agent who picks it up sees all of it.
Rendered on its own, <AskeloChat> provides its own widget and hides the
floating launcher (pass showLauncher to keep it). Rendered inside an existing
<AskeloProvider>, it uses that one — so an app that already mounts the
launcher does not end up with two widgets.
<AskeloProvider widgetId="your-widget-id">
<YourApp /> {/* launcher, everywhere */}
<Route path="/support"><AskeloChat /></Route> {/* and inline, here */}
</AskeloProvider>One thing worth knowing: an embedded chat reports the visitor as present only while it is actually on screen. Presence decides whether an agent's reply is also emailed, and a chat scrolled off the page is not being read — so scrolling away means the reply reaches the visitor's inbox too.
Telling the widget who your visitor is
If your app already knows who is signed in, pass them. The widget stops asking them to type their own email address to reach a human, and the agent who picks the conversation up sees their name.
<AskeloProvider widgetId="your-widget-id" user={{ externalId: user.id, email: user.email, name: user.name }}>
<YourApp />
</AskeloProvider>Changing the prop re-identifies. It never reloads the widget and never closes an open conversation — which matters most at exactly the moment it changes, since someone logging in mid-chat is the ordinary case. An inline object is fine: only its contents are compared, so a re-render costs nothing.
From anywhere under the provider, useAskelo().identify(user) does the same
thing — for a login callback, or after a profile edit changes the name.
An identity is a prefill and a label. It fills in the contact details on a support case and names the person to your agent. It is not authentication and it never widens what the widget can see.
Verified identities
Because that prop is rendered in a browser, by itself it is a claim: anyone who can open the page can make it. Turn on identity verification for your widget (Channels → Widget → your widget → Identity verification) and Askelo will only accept a claim your server has signed.
// on your server — e.g. the endpoint your app already calls on login
import { createHmac } from "node:crypto";
const subject = user.id ?? user.email; // externalId first, else email
const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
.update(subject)
.digest("hex");
return { externalId: user.id, email: user.email, name: user.name, signature };<AskeloProvider widgetId="your-widget-id" user={identity}>
<YourApp />
</AskeloProvider>The contract is HMAC-SHA256(key = your widget's secret, message = subject),
hex-encoded and lowercase, where subject is the externalId — or the email
when there is no externalId. Compute it on the server: a secret your frontend
can read is a secret any visitor can read.
Only that one field is proven. With an externalId, email rides beside
it unsigned — it prefills the case, but it is not trusted for anything an
action reads, like a Stripe or Shopify lookup scoped to the visitor's verified
email.
Proving both fields at once (v2)
If you need externalId and email trusted together, sign the versioned
message instead and send signatureVersion: 2 alongside:
// on your server
import { createHmac } from "node:crypto";
function v2Message(externalId: string, email: string) {
const normalizedEmail = email.trim().toLowerCase();
return `v2\nuid:${externalId.length}:${externalId}\nemail:${normalizedEmail.length}:${normalizedEmail}`;
}
const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
.update(v2Message(user.id, user.email))
.digest("hex");
return { externalId: user.id, email: user.email, name: user.name, signature, signatureVersion: 2 };{/* identical shape — signatureVersion just rides along */}
<AskeloProvider widgetId="your-widget-id" user={identity}>
<YourApp />
</AskeloProvider>Both fields are length-prefixed in the message so no value either one can
contain — a newline, a colon — can shift where uid ends and email begins.
email is trimmed and lowercased before it enters the message; sign it the
same way on your server. Existing integrations that sign only externalId or
only email keep working unchanged — v2 is opt-in per identify call, not a
widget-wide setting.
Once a secret is set, an unsigned or wrongly signed identity is refused, not recorded as unverified. So deploy your signing code first, or turn verification on after your integration is live. Until you set one, identities are accepted and your agents see them marked as unverified.
Next.js
The components are client components ("use client"). In the App Router, render
them from a client component or a client boundary:
// app/providers.tsx
"use client";
import { AskeloProvider } from "@askelo/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <AskeloProvider widgetId={process.env.NEXT_PUBLIC_ASKELO_WIDGET_ID!}>{children}</AskeloProvider>;
}Nothing loads during SSR. The widget is fetched from an effect after hydration, so it adds nothing to your server-rendered HTML.
API
<AskeloProvider> / <AskeloWidget>
| Prop | Type | Description |
| --- | --- | --- |
| widgetId | string | Required. From your dashboard. |
| cdnUrl | string | Only if you were given a dedicated CDN origin. Defaults to Askelo's. |
| hideLauncher | boolean | Suppress the built-in launcher and drive the panel yourself. |
| timeoutMs | number | How long to wait before failing. Default 20000; 0 waits forever. |
| user | AskeloVisitor | Who the visitor is. Changing it re-identifies; it never reloads the widget. |
| theme | AskeloTheme | Panel accent, font and colour scheme. Changing it re-themes in place. See Styling. |
| enabled | boolean | Default true. Set false for a consent gate — flipping it tears the widget down. |
| onLoad | (widget) => void | Called once with the handle when the widget is ready. |
| onError | (error: AskeloError) => void | Called if it never comes up. |
<AskeloChat>
Everything <AskeloProvider> takes except hideLauncher, plus:
| Prop | Type | Description |
| --- | --- | --- |
| showLauncher | boolean | Keep the floating bubble alongside the embedded chat. Off by default. |
| className | string | Sizing is yours when you pass one — no default height is applied. |
| style | CSSProperties | Defaults to height: 640 when you set neither this nor className. |
widgetId is required unless there is an <AskeloProvider> above it.
useAskelo()
const {
status, // "idle" | "loading" | "ready" | "error"
error, // AskeloError | null
isOpen, // whether the panel is on screen
unreadCount, // unread agent replies since the panel was last open
widget, // the underlying handle, or null
open, close, toggle,
identify, // (user: AskeloVisitor) => Promise<void>
} = useAskelo();open, close and toggle are safe to call before the widget is ready — the
call is dropped rather than queued, so a visitor who clicks during a slow load
and navigates away does not get a panel opening itself on the next page.
identify is the exception: an early call is queued and sent once the
widget is up. A host app that knows its user faster than the CDN answers should
not have to forget who they are.
Errors
onError and error receive an AskeloError with a code:
| Code | Meaning |
| --- | --- |
| invalid_widget_id | widgetId was empty or not a string. |
| invalid_cdn_url | cdnUrl was set but was not an http(s) URL. |
| script_load_failed | The loader script could not be fetched. |
| bootstrap_failed | The widget did not start — usually a wrong id, or an origin that is not on the allow-list. |
| timeout | Nothing came back within timeoutMs. |
| no_browser | Called outside a browser. |
Notes
- One widget per page. Mounting two providers for the same widget is
harmless — they share one widget, torn down when the last unmounts — and if
the page also carries the old
<script>snippet, the package adopts the running widget rather than adding a second. - React 18 and 19, and StrictMode's double mount, are supported.
- Vanilla JS, or another framework?
@askelo/browseris the layer underneath this one.
