Chat widget
A support chat for websites. A launcher with a help home, a conversation with an AI note and quick replies, the handoff to a person with a queue, a lead form inside the chat and an AI answer embedded in a help page.
One of 2 components in AI assistants
Variants
Details
What Chat widget brings along, read from the registry and the source.
- Category
- AI assistants
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/chat-widget.tsx
- Brings along
- utilskiticonreveal
- npm packages
- None
- Languages
- English and German, 40 texts of its own
- Studio
- All 5 variants editable
- In collections
- AI product
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ChatWidgetProps interface from components/ui/chat-widget.tsx, with the comments from the code.
variantoptionalChatWidgetVariantlauncherconversationhandofflead-capturehelp-embed
namerequiredstringName of the assistant, e.g. "Lumen Help"
taglineoptionalstringShort line under the name, e.g. "Answers right away"
greetingoptionalstringactionsoptionalWidgetAction[]articlesoptionalWidgetArticle[]messagesoptionalWidgetMessage[]suggestionsoptionalstring[]Quick replies under the last message
agentoptional{ name: string; role?: string; initials?: string; /** First message after joining */ greeting?: string }handoff: the person who takes over
queueoptional{ position: number; wait: string }handoff: position in the queue and the expected wait
questionoptionalstringhelp-embed: the question already asked and its answer
answeroptionalstringdefaultOpenoptionalbooleanStart open
positionoptional'fixed' | 'contained'contained keeps the widget inside its parent (for previews), fixed pins it to the window
backdropoptionalReact.ReactNodecontained: the page behind the widget
respondoptional(text: string) => string | Promise<string>Produces the bot reply, without it the widget answers with a short built-in text
onLeadoptional(lead: { name: string; email: string }) => void
Built-in texts
Chat widget writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 40 texts
| Key | English | German |
|---|---|---|
| open | English Open chat | German Chat öffnen |
| close | English Close chat | German Chat schließen |
| message | English Write a message | German Nachricht schreiben |
| send | English Send | German Senden |
| online | English Answers right away | German Antwortet sofort |
| aiNote | English Answers come from an AI and can be wrong. A person takes over on request. | German Antworten kommen von einer KI und können falsch sein. Auf Wunsch übernimmt ein Mensch. |
| typing | English {name} is typing | German {name} schreibt |
| ask | English Ask a question | German Frage stellen |
| popular | English Popular articles | German Beliebte Artikel |
| teaser | English Hi, can I help you find something? | German Hallo, kann ich dir beim Finden helfen? |
| dismiss | English Dismiss | German Ausblenden |
| fallback | English Thanks for your message. I found two articles that may help, and a person from the team reads along if you need more. | German Danke für deine Nachricht. Ich habe zwei Artikel gefunden, die helfen könnten, und jemand aus dem Team liest mit, falls du mehr brauchst. |
| queue | English You are number {n} in line | German Du bist Nummer {n} in der Warteschlange |
| wait | English Usually {wait} | German Meist {wait} |
| joined | English {name} joined the chat | German {name} ist dem Chat beigetreten |
| now | English now | German jetzt |
| left | English Leave the queue | German Warteschlange verlassen |
| emailInstead | English Get the answer by email instead | German Antwort lieber per E-Mail |
| emailAsk | English Write your email address below and the team replies there. | German Schreib unten deine E-Mail-Adresse, dann antwortet das Team dort. |
| leftNote | English You left the queue. The assistant keeps answering here. | German Du hast die Warteschlange verlassen. Der Assistent antwortet hier weiter. |
| leadTitle | English Where can we reach you? | German Wie erreichen wir dich? |
| leadNote | English So the team can answer if you close this window. | German Damit das Team antworten kann, auch wenn du das Fenster schließt. |
| name | English Name | German Name |
| English Email | German E-Mail | |
| consent | English I agree that my details are used to answer this request. | German Ich bin einverstanden, dass meine Angaben für diese Anfrage genutzt werden. |
| privacy | English Privacy policy | German Datenschutzerklärung |
| submit | English Send details | German Angaben senden |
| leadDone | English Thanks, {name}. We will reply to {email}. | German Danke, {name}. Wir antworten an {email}. |
| invalidEmail | English Enter an email like name@example.com | German Gib eine E-Mail wie name@beispiel.de ein |
| required | English Fill in name and email | German Name und E-Mail ausfüllen |
| searchHelp | English Search help or ask a question | German Hilfe durchsuchen oder Frage stellen |
| stillHelp | English Still need help? | German Noch Fragen offen? |
| contact | English Write to the team | German Dem Team schreiben |
| helpful | English Did this answer your question? | German Hat das deine Frage beantwortet? |
| yes | English Yes | German Ja |
| no | English No | German Nein |
| thanks | English Thanks for the feedback. | German Danke für die Rückmeldung. |
| answerFrom | English Answer from {name} | German Antwort von {name} |
| readMore | English Read the full article | German Ganzen Artikel lesen |
| poweredNote | English Generated from our help articles. Check details in the article. | German Aus unseren Hilfeartikeln erstellt. Details stehen im Artikel. |
Accessibility
Read from the source of the component.
- Chat widget handles the keys Escape, Enter and Space itself.
- Sets aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the roles alert, dialog, search and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 848 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
export type ChatWidgetVariant = 'launcher' | 'conversation' | 'handoff' | 'lead-capture' | 'help-embed';
export interface WidgetMessage {
id: string;
from: 'bot' | 'user' | 'agent';
text: string;
time?: string;
sources?: { title: string; href: string }[];
}
export interface WidgetAction {
id: string;
label: string;
icon?: IconName;
/** What the visitor sends when choosing it */
prompt?: string;
}
export interface WidgetArticle {
id: string;
title: string;
/** e.g. "2 min read" */
meta?: string;
href?: string;
}
export interface ChatWidgetProps {
variant?: ChatWidgetVariant;
/** Name of the assistant, e.g. "Lumen Help" */
name: string;
/** Short line under the name, e.g. "Answers right away" */
tagline?: string;
greeting?: string;
actions?: WidgetAction[];
articles?: WidgetArticle[];
messages?: WidgetMessage[];
/** Quick replies under the last message */
suggestions?: string[];
/** handoff: the person who takes over */
agent?: { name: string; role?: string; initials?: string; /** First message after joining */ greeting?: string };
/** handoff: position in the queue and the expected wait */
queue?: { position: number; wait: string };
/** help-embed: the question already asked and its answer */
question?: string;
answer?: string;
/** Start open */
defaultOpen?: boolean;
/** contained keeps the widget inside its parent (for previews), fixed pins it to the window */
position?: 'fixed' | 'contained';
/** contained: the page behind the widget */
backdrop?: React.ReactNode;
/** Produces the bot reply, without it the widget answers with a short built-in text */
respond?: (text: string) => string | Promise<string>;
onLead?: (lead: { name: string; email: string }) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
open: 'Open chat',
close: 'Close chat',
message: 'Write a message',
send: 'Send',
online: 'Answers right away',
aiNote: 'Answers come from an AI and can be wrong. A person takes over on request.',
typing: '{name} is typing',
ask: 'Ask a question',
popular: 'Popular articles',
teaser: 'Hi, can I help you find something?',
dismiss: 'Dismiss',
fallback: 'Thanks for your message. I found two articles that may help, and a person from the team reads along if you need more.',
queue: 'You are number {n} in line',
wait: 'Usually {wait}',
joined: '{name} joined the chat',
now: 'now',
left: 'Leave the queue',
emailInstead: 'Get the answer by email instead',
emailAsk: 'Write your email address below and the team replies there.',
leftNote: 'You left the queue. The assistant keeps answering here.',
leadTitle: 'Where can we reach you?',
leadNote: 'So the team can answer if you close this window.',
name: 'Name',
email: 'Email',
consent: 'I agree that my details are used to answer this request.',
privacy: 'Privacy policy',
submit: 'Send details',
leadDone: 'Thanks, {name}. We will reply to {email}.',
invalidEmail: 'Enter an email like name@example.com',
required: 'Fill in name and email',
searchHelp: 'Search help or ask a question',
stillHelp: 'Still need help?',
contact: 'Write to the team',
helpful: 'Did this answer your question?',
yes: 'Yes',
no: 'No',
thanks: 'Thanks for the feedback.',
answerFrom: 'Answer from {name}',
readMore: 'Read the full article',
poweredNote: 'Generated from our help articles. Check details in the article.',
},
de: {
open: 'Chat öffnen',
close: 'Chat schließen',
message: 'Nachricht schreiben',
send: 'Senden',
online: 'Antwortet sofort',
aiNote: 'Antworten kommen von einer KI und können falsch sein. Auf Wunsch übernimmt ein Mensch.',
typing: '{name} schreibt',
ask: 'Frage stellen',
popular: 'Beliebte Artikel',
teaser: 'Hallo, kann ich dir beim Finden helfen?',
dismiss: 'Ausblenden',
fallback: 'Danke für deine Nachricht. Ich habe zwei Artikel gefunden, die helfen könnten, und jemand aus dem Team liest mit, falls du mehr brauchst.',
queue: 'Du bist Nummer {n} in der Warteschlange',
wait: 'Meist {wait}',
joined: '{name} ist dem Chat beigetreten',
now: 'jetzt',
left: 'Warteschlange verlassen',
emailInstead: 'Antwort lieber per E-Mail',
emailAsk: 'Schreib unten deine E-Mail-Adresse, dann antwortet das Team dort.',
leftNote: 'Du hast die Warteschlange verlassen. Der Assistent antwortet hier weiter.',
leadTitle: 'Wie erreichen wir dich?',
leadNote: 'Damit das Team antworten kann, auch wenn du das Fenster schließt.',
name: 'Name',
email: 'E-Mail',
consent: 'Ich bin einverstanden, dass meine Angaben für diese Anfrage genutzt werden.',
privacy: 'Datenschutzerklärung',
submit: 'Angaben senden',
leadDone: 'Danke, {name}. Wir antworten an {email}.',
invalidEmail: 'Gib eine E-Mail wie name@beispiel.de ein',
required: 'Name und E-Mail ausfüllen',
searchHelp: 'Hilfe durchsuchen oder Frage stellen',
stillHelp: 'Noch Fragen offen?',
contact: 'Dem Team schreiben',
helpful: 'Hat das deine Frage beantwortet?',
yes: 'Ja',
no: 'Nein',
thanks: 'Danke für die Rückmeldung.',
answerFrom: 'Antwort von {name}',
readMore: 'Ganzen Artikel lesen',
poweredNote: 'Aus unseren Hilfeartikeln erstellt. Details stehen im Artikel.',
},
};
type Texts = (typeof TEXTS)['en'];
type P = ChatWidgetProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value);
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes cwg-up{from{opacity:0;transform:translateY(8px)}}@keyframes cwg-open{from{opacity:0;transform:translateY(16px) scale(.96)}}@keyframes cwg-dot{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-3px);opacity:1}}@keyframes cwg-ring{0%{box-shadow:0 0 0 0 color-mix(in oklab,var(--primary) 45%,transparent)}100%{box-shadow:0 0 0 14px transparent}}' +
'@media (prefers-reduced-motion:no-preference){.cwg-up{animation:cwg-up .4s var(--ease-kit) var(--cwg-d,0ms) both}.cwg-open{transform-origin:100% 100%;animation:cwg-open .35s var(--ease-kit) both}.cwg-dot{animation:cwg-dot 1.2s ease-in-out infinite}.cwg-ring{animation:cwg-ring 2.4s ease-out 1s 3}}';
function Style() {
return (
<style href="kit-chat-widget" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
function BotMark({ size = 32 }: { size?: number }) {
return (
<span aria-hidden className="grid shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2)]" style={{ width: size, height: size }}>
<Icon name="sparkle" size={Math.round(size * 0.5)} />
</span>
);
}
function Person({ initials, size = 32 }: { initials: string; size?: number }) {
return (
<span aria-hidden className="grid shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_22%,var(--card))] text-[12px] font-semibold text-success-foreground" style={{ width: size, height: size }}>
{initials}
</span>
);
}
/** The page frame a contained widget sits in */
function Frame({ position, backdrop, children, className }: { position?: 'fixed' | 'contained'; backdrop?: React.ReactNode; children: React.ReactNode; className?: string }) {
if (position === 'fixed') return <>{children}</>;
return (
<section className={cn('bg-surface py-10 sm:py-14', className)}>
<div className="kit-container">
<Reveal effect="scale">
<div className="relative h-[46rem] overflow-hidden rounded-2xl border border-border bg-background shadow-[var(--highlight),var(--shadow-md)] sm:h-[48rem]">
<div aria-hidden className="pointer-events-none h-full select-none">
{backdrop}
</div>
{children}
</div>
</Reveal>
</div>
</section>
);
}
/** The corner the widget lives in: fixed to the window or absolute in the frame */
const corner = (position?: 'fixed' | 'contained') => (position === 'fixed' ? 'fixed bottom-4 right-4 z-50 sm:bottom-6 sm:right-6' : 'absolute bottom-4 right-4 z-10 sm:bottom-5 sm:right-5');
function Header({ name, tagline, t, onClose, agent }: { name: string; tagline?: string; t: Texts; onClose?: () => void; agent?: ChatWidgetProps['agent'] }) {
return (
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<span className="relative">
{agent ? <Person initials={agent.initials ?? agent.name.slice(0, 2)} size={36} /> : <BotMark size={36} />}
<span aria-hidden className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-card bg-success" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[14.5px] font-semibold text-foreground">{agent ? agent.name : name}</p>
<p className="truncate text-[12px] text-muted-foreground">{agent ? agent.role : tagline ?? t.online}</p>
</div>
{onClose && (
<button type="button" aria-label={t.close} onClick={onClose} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground sm:size-9">
<Icon name="chevron-down" size={18} />
</button>
)}
</div>
);
}
/** The panel: a sheet over the whole frame on phones, a card in the corner from sm */
function Panel({ position, children, labelledBy, onClose }: { position?: 'fixed' | 'contained'; children: React.ReactNode; labelledBy?: string; onClose?: () => void }) {
return (
<div
role="dialog"
aria-labelledby={labelledBy}
onKeyDown={(e) => {
// Escape closes the panel and hands the focus back to the launcher
if (e.key !== 'Escape' || !onClose) return;
e.stopPropagation();
const root = e.currentTarget.parentElement;
onClose();
requestAnimationFrame(() => root?.querySelector<HTMLButtonElement>('[data-cwg-launcher]')?.focus());
}}
className={cn(
'cwg-open flex flex-col overflow-hidden border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]',
position === 'fixed' ? 'fixed inset-x-0 bottom-0 z-50 h-[min(40rem,100%)] rounded-t-2xl sm:inset-x-auto sm:bottom-24 sm:right-6 sm:h-[38rem] sm:w-[24rem] sm:rounded-2xl' : 'absolute inset-x-2 bottom-2 top-14 z-10 rounded-2xl sm:inset-x-auto sm:bottom-[5.5rem] sm:right-5 sm:top-auto sm:h-[39rem] sm:w-[23.5rem]',
)}
>
{children}
</div>
);
}
function Launcher({ open, onClick, t, position, unread }: { open: boolean; onClick: () => void; t: Texts; position?: 'fixed' | 'contained'; unread?: number }) {
return (
<button
type="button"
data-cwg-launcher
aria-label={open ? t.close : t.open}
aria-expanded={open}
onClick={onClick}
className={cn(corner(position), 'grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 active:scale-95 motion-reduce:hover:scale-100', !open && 'cwg-ring', open && 'max-sm:hidden')}
>
<Icon name={open ? 'x' : 'chat'} size={24} className="transition-transform duration-300" />
{!open && !!unread && <span className="absolute -right-0.5 -top-0.5 grid size-5 place-items-center rounded-full border-2 border-background bg-destructive text-[10.5px] font-semibold text-destructive-foreground">{unread}</span>}
</button>
);
}
function Bubble({ m, t, agent, name }: { m: WidgetMessage; t: Texts; agent?: ChatWidgetProps['agent']; name: string }) {
if (m.from === 'user')
return (
<div className="cwg-up flex justify-end">
<p className="max-w-[82%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[14px] leading-relaxed text-primary-foreground">{m.text}</p>
</div>
);
return (
<div className="cwg-up flex items-end gap-2">
{m.from === 'agent' ? <Person initials={agent?.initials ?? 'AB'} size={26} /> : <BotMark size={26} />}
<div className="min-w-0 max-w-[85%]">
<div className="rounded-2xl rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] px-3.5 py-2.5 text-[14px] leading-relaxed text-foreground">
{m.text}
{!!m.sources?.length && (
<span className="mt-2 flex flex-col gap-1 border-t border-border pt-2">
{m.sources.map((s) => (
<a key={s.title} href={s.href} className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-medium text-foreground underline-offset-4 hover:underline sm:min-h-8">
<Icon name="book-open" size={13} className="shrink-0 text-muted-foreground" />
<span className="truncate">{s.title}</span>
</a>
))}
</span>
)}
</div>
{m.time && (
<p className="mt-1 px-1 text-[11px] text-subtle-foreground">
{m.from === 'agent' ? agent?.name : name} · {m.time}
</p>
)}
</div>
</div>
);
}
function Typing({ t, name }: { t: Texts; name: string }) {
return (
<div className="cwg-up flex items-end gap-2" role="status" aria-label={fill(t.typing, { name })}>
<BotMark size={26} />
<span className="flex gap-1 rounded-2xl rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] px-3.5 py-3.5">
{[0, 1, 2].map((i) => (
<span key={i} className="cwg-dot size-1.5 rounded-full bg-muted-foreground" style={{ animationDelay: `${i * 160}ms` }} />
))}
</span>
</div>
);
}
function Composer({ t, onSend, disabled }: { t: Texts; onSend: (text: string) => void; disabled?: boolean }) {
const [text, setText] = useState('');
return (
<form
className="flex items-end gap-2 border-t border-border p-3"
onSubmit={(e) => {
e.preventDefault();
if (!text.trim()) return;
onSend(text.trim());
setText('');
}}
>
<label className="min-w-0 flex-1">
<span className="sr-only">{t.message}</span>
<textarea
rows={1}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
placeholder={t.message}
className="block max-h-28 min-h-11 w-full resize-none rounded-xl border border-border bg-background px-3.5 py-2.5 text-[14px] text-foreground outline-none [field-sizing:content] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none"
/>
</label>
<button type="submit" aria-label={t.send} disabled={!text.trim() || disabled} className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground transition-colors disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground">
<Icon name="send" size={17} />
</button>
</form>
);
}
/** The thread with a reply engine */
function useThread(initial: WidgetMessage[], respond: ChatWidgetProps['respond'], fallback: string) {
const [messages, setMessages] = useSynced(initial);
const [typing, setTyping] = useState(false);
const seq = useRef(0);
const send = async (text: string) => {
setMessages((m) => [...m, { id: `u${++seq.current}`, from: 'user', text }]);
setTyping(true);
const reply = respond ? await respond(text) : (await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 1100)), fallback);
setTyping(false);
setMessages((m) => [...m, { id: `b${++seq.current}`, from: 'bot', text: reply }]);
};
return { messages, setMessages, typing, setTyping, send };
}
function Scroll({ children, deps }: { children: React.ReactNode; deps: unknown }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [deps]);
return (
<div ref={ref} className="kit-scrollbar relative min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4 [mask-image:linear-gradient(to_bottom,transparent,black_1.25rem)]">
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
/* launcher */
/* ------------------------------------------------------------------ */
function LauncherHome({ t, name, tagline, greeting, actions = [], articles = [], defaultOpen, position = 'contained', backdrop, respond, className }: P) {
const [open, setOpen] = useState(!!defaultOpen);
const [teaser, setTeaser] = useState(!defaultOpen);
const [chat, setChat] = useState(false);
const thread = useThread([], respond, t.fallback);
const id = useId();
const go = (text: string) => {
setChat(true);
thread.send(text);
};
return (
<Frame position={position} backdrop={backdrop} className={className}>
{teaser && !open && (
<div className={cn(position === 'fixed' ? 'fixed bottom-24 right-4 z-50 sm:right-6' : 'absolute bottom-[5.5rem] right-4 z-10 sm:right-5', 'cwg-open flex max-w-[16rem] items-start gap-2 rounded-2xl rounded-br-md border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-lg)]')}>
<button type="button" onClick={() => (setOpen(true), setTeaser(false))} className="text-left text-[13.5px] leading-snug text-foreground">
{greeting ?? t.teaser}
</button>
<button type="button" aria-label={t.dismiss} onClick={() => setTeaser(false)} className="relative -m-1 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground after:absolute after:-inset-1.5 after:content-[''] hover:bg-accent hover:text-foreground">
<Icon name="x" size={14} />
</button>
</div>
)}
{open && (
<Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
{chat ? (
<>
<Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} />
<Scroll deps={`${thread.messages.length}-${thread.typing}`}>
{thread.messages.map((m) => (
<Bubble key={m.id} m={m} t={t} name={name} />
))}
{thread.typing && <Typing t={t} name={name} />}
</Scroll>
<Composer t={t} onSend={thread.send} disabled={thread.typing} />
</>
) : (
<>
<div className="relative overflow-hidden bg-[linear-gradient(160deg,var(--primary),color-mix(in_oklab,var(--primary)_80%,var(--primary-foreground)))] px-5 pb-14 pt-5 text-primary-foreground">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span aria-hidden className="grid size-8 place-items-center rounded-full bg-[rgb(255_255_255/0.2)]">
<Icon name="sparkle" size={16} />
</span>
<span id={`${id}-h`} className="text-[14px] font-semibold">
{name}
</span>
</span>
<button type="button" aria-label={t.close} onClick={() => setOpen(false)} className="grid size-11 place-items-center rounded-lg text-primary-foreground/85 hover:bg-[rgb(255_255_255/0.15)] sm:size-9">
<Icon name="chevron-down" size={18} />
</button>
</div>
<p className="cwg-up mt-6 text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em]">{greeting ?? t.teaser}</p>
</div>
<div className="kit-scrollbar relative -mt-8 min-h-0 flex-1 space-y-3 overflow-y-auto px-3 pb-3">
<button type="button" onClick={() => setChat(true)} className="cwg-up flex w-full items-center gap-3 rounded-xl border border-border bg-card p-3.5 text-left focus-visible:-outline-offset-2 shadow-[var(--highlight),var(--shadow-md)] transition-colors hover:border-border-strong" style={{ '--cwg-d': '80ms' } as React.CSSProperties}>
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-semibold text-foreground">{t.ask}</span>
<span className="block text-[12.5px] text-muted-foreground">{tagline ?? t.online}</span>
</span>
<Icon name="send" size={17} className="text-primary" />
</button>
{actions.length > 0 && (
<div className="cwg-up grid grid-cols-2 gap-2" style={{ '--cwg-d': '140ms' } as React.CSSProperties}>
{actions.map((a) => (
<button key={a.id} type="button" onClick={() => go(a.prompt ?? a.label)} className="flex min-h-12 items-center gap-2 rounded-xl border border-border bg-card px-3 py-2.5 text-left focus-visible:-outline-offset-2 text-[13px] font-medium text-foreground transition-colors hover:bg-accent">
<Icon name={a.icon ?? 'sparkle'} size={16} className="shrink-0 text-muted-foreground" />
<span className="leading-snug">{a.label}</span>
</button>
))}
</div>
)}
{articles.length > 0 && (
<div className="cwg-up rounded-xl border border-border bg-card" style={{ '--cwg-d': '200ms' } as React.CSSProperties}>
<p className="px-3.5 pb-1 pt-3 text-[12px] font-medium text-subtle-foreground">{t.popular}</p>
<ul className="pb-1.5">
{articles.map((a) => (
<li key={a.id}>
<a href={a.href ?? '#'} className="flex min-h-11 items-center gap-3 px-3.5 py-2 text-[13.5px] text-foreground focus-visible:-outline-offset-2 hover:bg-accent/60">
<span className="min-w-0 flex-1">
<span className="block truncate">{a.title}</span>
{a.meta && <span className="block text-[11.5px] text-muted-foreground">{a.meta}</span>}
</span>
<Icon name="chevron-right" size={15} className="shrink-0 text-muted-foreground" />
</a>
</li>
))}
</ul>
</div>
)}
</div>
</>
)}
</Panel>
)}
<Launcher open={open} onClick={() => (setOpen((o) => !o), setTeaser(false))} t={t} position={position} unread={teaser ? 1 : 0} />
</Frame>
);
}
/* ------------------------------------------------------------------ */
/* conversation */
/* ------------------------------------------------------------------ */
function Conversation({ t, name, tagline, messages = [], suggestions = [], position = 'contained', backdrop, respond, defaultOpen = true, className }: P) {
const [open, setOpen] = useState(defaultOpen);
const thread = useThread(messages, respond, t.fallback);
const id = useId();
return (
<Frame position={position} backdrop={backdrop} className={className}>
{open && (
<Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
<span id={`${id}-h`} className="sr-only">
{name}
</span>
<Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} />
<p className="flex items-start gap-1.5 border-b border-border bg-surface/60 px-4 py-2 text-[11.5px] leading-snug text-muted-foreground">
<Icon name="info" size={13} className="mt-px shrink-0" />
{t.aiNote}
</p>
<Scroll deps={`${thread.messages.length}-${thread.typing}`}>
{thread.messages.map((m) => (
<Bubble key={m.id} m={m} t={t} name={name} />
))}
{thread.typing && <Typing t={t} name={name} />}
</Scroll>
{!thread.typing && suggestions.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-3 pb-1">
{suggestions.map((s) => (
<button key={s} type="button" onClick={() => thread.send(s)} className="cwg-up inline-flex min-h-9 items-center rounded-full border border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] px-3 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] max-sm:min-h-11">
{s}
</button>
))}
</div>
)}
<Composer t={t} onSend={thread.send} disabled={thread.typing} />
</Panel>
)}
<Launcher open={open} onClick={() => setOpen((o) => !o)} t={t} position={position} />
</Frame>
);
}
/* ------------------------------------------------------------------ */
/* handoff */
/* ------------------------------------------------------------------ */
function Handoff({ t, name, tagline, messages = [], agent, queue, position = 'contained', backdrop, respond, defaultOpen = true, className }: P) {
const [open, setOpen] = useState(defaultOpen);
const [stage, setStage] = useState<'queue' | 'joined' | 'left' | 'email'>('queue');
const [pos, setPos] = useState(queue?.position ?? 2);
const thread = useThread(messages, respond, t.fallback);
const [ref, inView] = useInView<HTMLDivElement>();
const id = useId();
// The queue moves up, then the person joins and greets
useEffect(() => {
if (!inView || stage !== 'queue' || prefersReducedMotion()) return;
const step = window.setTimeout(() => {
if (pos > 1) return setPos((p) => p - 1);
setStage('joined');
if (agent?.greeting) thread.setMessages((m) => [...m, { id: 'agent-hello', from: 'agent', text: agent.greeting!, time: t.now }]);
}, 1800);
return () => window.clearTimeout(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView, stage, pos]);
return (
<Frame position={position} backdrop={backdrop} className={className}>
{open && (
<Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
<span id={`${id}-h`} className="sr-only">
{name}
</span>
<div ref={ref} className="contents">
<Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} agent={stage === 'joined' ? agent : undefined} />
<Scroll deps={`${thread.messages.length}-${stage}`}>
{thread.messages.map((m, i) => (
<div key={m.id} className="space-y-3">
{/* The line goes before the first message of the person */}
{agent && m.from === 'agent' && thread.messages.findIndex((x) => x.from === 'agent') === i && (
<p className="cwg-up flex items-center gap-2 py-1 text-[12px] text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border" role="status">
{fill(t.joined, { name: agent.name })}
</p>
)}
<Bubble m={m} t={t} name={name} agent={agent} />
</div>
))}
</Scroll>
{stage === 'queue' && (
<div className="cwg-up border-t border-border p-4" aria-live="polite">
<div className="flex items-center gap-3">
<span className="relative grid size-11 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[17px] font-semibold tabular-nums text-primary">
<span key={pos} className="cwg-up">
{pos}
</span>
<span aria-hidden className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-primary [animation-duration:2.4s] motion-reduce:animate-none" />
</span>
<div className="min-w-0">
<p className="text-[14px] font-medium text-foreground">{fill(t.queue, { n: pos })}</p>
<p className="text-[12.5px] text-muted-foreground">{queue && fill(t.wait, { wait: queue.wait })}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => (setStage('email'), thread.setMessages((m) => [...m, { id: 'email-ask', from: 'bot', text: t.emailAsk }]))}
className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground hover:bg-accent sm:h-9"
>
<Icon name="mail" size={14} />
{t.emailInstead}
</button>
<button type="button" onClick={() => (setStage('left'), thread.setMessages((m) => [...m, { id: 'left', from: 'bot', text: t.leftNote }]))} className="inline-flex h-11 items-center rounded-lg px-3 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-9">
{t.left}
</button>
</div>
</div>
)}
{stage !== 'queue' && <Composer t={t} onSend={thread.send} />}
</div>
</Panel>
)}
<Launcher open={open} onClick={() => setOpen((o) => !o)} t={t} position={position} />
</Frame>
);
}
/* ------------------------------------------------------------------ */
/* lead-capture */
/* ------------------------------------------------------------------ */
function LeadCapture({ t, name, tagline, messages = [], position = 'contained', backdrop, onLead, defaultOpen = true, className }: P) {
const [open, setOpen] = useState(defaultOpen);
const [lead, setLead] = useState({ name: '', email: '' });
const [agree, setAgree] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const id = useId();
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(lead.email);
const input = 'h-11 w-full rounded-lg border border-border bg-background px-3 text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none aria-[invalid=true]:border-destructive';
return (
<Frame position={position} backdrop={backdrop} className={className}>
{open && (
<Panel position={position} labelledBy={`${id}-h`} onClose={() => setOpen(false)}>
<span id={`${id}-h`} className="sr-only">
{name}
</span>
<Header name={name} tagline={tagline} t={t} onClose={() => setOpen(false)} />
<Scroll deps={done}>
{messages.map((m) => (
<Bubble key={m.id} m={m} t={t} name={name} />
))}
{done ? (
<Bubble m={{ id: 'done', from: 'bot', text: fill(t.leadDone, { name: lead.name.split(' ')[0], email: lead.email }) }} t={t} name={name} />
) : (
<form
noValidate
className="cwg-up ml-[34px] rounded-2xl border border-border bg-background/60 p-3.5"
onSubmit={(e) => {
e.preventDefault();
if (!lead.name.trim() || !lead.email.trim()) return setError(t.required);
if (!ok) return setError(t.invalidEmail);
setError('');
onLead?.(lead);
setDone(true);
}}
>
<p className="text-[14px] font-semibold text-foreground">{t.leadTitle}</p>
<p className="mt-0.5 text-[12.5px] leading-snug text-muted-foreground">{t.leadNote}</p>
<label className="mt-3 block">
<span className="mb-1 block text-[12.5px] font-medium text-foreground">{t.name}</span>
<input autoComplete="name" value={lead.name} onChange={(e) => setLead((l) => ({ ...l, name: e.target.value }))} aria-invalid={!!error && !lead.name.trim()} className={input} />
</label>
<label className="mt-2.5 block">
<span className="mb-1 block text-[12.5px] font-medium text-foreground">{t.email}</span>
<input type="email" autoComplete="email" inputMode="email" value={lead.email} onChange={(e) => setLead((l) => ({ ...l, email: e.target.value }))} aria-invalid={!!error && !ok} aria-describedby={error ? `${id}-err` : undefined} className={input} />
</label>
<label className="mt-2 flex min-h-11 cursor-pointer items-start gap-2 py-1.5">
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
<span className="text-[12px] leading-snug text-muted-foreground">
{t.consent}{' '}
<a href="#" className="font-medium text-foreground underline underline-offset-2">
{t.privacy}
</a>
</span>
</label>
{error && (
<p id={`${id}-err`} role="alert" className="cwg-up mt-2 flex items-center gap-1.5 text-[12.5px] text-destructive">
<Icon name="alert-circle" size={14} />
{error}
</p>
)}
<button type="submit" disabled={!agree} className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary text-[13.5px] font-medium text-primary-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground">
{t.submit}
</button>
</form>
)}
</Scroll>
</Panel>
)}
<Launcher open={open} onClick={() => setOpen((o) => !o)} t={t} position={position} />
</Frame>
);
}
/* ------------------------------------------------------------------ */
/* help-embed */
/* ------------------------------------------------------------------ */
function HelpEmbed({ t, name, question = '', answer = '', articles = [], respond, className }: P) {
const [q, setQ] = useState(question);
const [asked, setAsked] = useState(question);
const [reply, setReply] = useState(answer);
// Every search plays the answer in again, also when the question stays the same
const [run, setRun] = useState(0);
const [vote, setVote] = useState<'yes' | 'no' | null>(null);
return (
<section className={cn('kit-glow bg-surface py-12 sm:py-16 lg:py-20', className)}>
<div className="kit-container">
<div className="mx-auto max-w-[44rem]">
<Reveal>
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
if (!q.trim()) return;
setAsked(q);
setRun((r) => r + 1);
setVote(null);
// A new question gets a new answer when a reply engine is connected
if (respond) Promise.resolve(respond(q)).then(setReply);
}}
className="flex items-center gap-2 rounded-2xl border border-border bg-card p-2 pl-4 shadow-[var(--highlight),var(--shadow-md)] focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
>
<Icon name="search" size={18} className="shrink-0 text-muted-foreground" />
<label className="min-w-0 flex-1">
<span className="sr-only">{t.searchHelp}</span>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchHelp} className="h-11 w-full bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
</label>
<button type="submit" className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-xl bg-primary px-4 text-[13.5px] font-medium text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
<Icon name="sparkle" size={15} />
<span className="max-sm:sr-only">{t.ask}</span>
</button>
</form>
</Reveal>
{asked && (
<Reveal delay={120}>
<div key={`${asked}-${run}`} className="cwg-up mt-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<p className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
<BotMark size={22} />
{fill(t.answerFrom, { name })}
</p>
<p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-foreground">{respond ? reply : answer}</p>
{articles[0] && (
<a href={articles[0].href ?? '#'} className="mt-3 inline-flex min-h-9 items-center gap-1.5 text-[13.5px] font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11">
{t.readMore}
<Icon name="arrow-right" size={14} />
</a>
)}
<p className="mt-3 text-[12px] text-subtle-foreground">{t.poweredNote}</p>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4" aria-live="polite">
{vote ? (
<span className="cwg-up flex items-center gap-1.5 text-[13px] text-success-foreground">
<Icon name="check-circle" size={15} />
{t.thanks}
</span>
) : (
<>
<span className="mr-1 text-[13px] text-muted-foreground max-sm:w-full">{t.helpful}</span>
{(['yes', 'no'] as const).map((v) => (
<button key={v} type="button" onClick={() => setVote(v)} className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8">
<Icon name={v === 'yes' ? 'thumbs-up' : 'thumbs-down'} size={14} />
{t[v]}
</button>
))}
</>
)}
</div>
</div>
</Reveal>
)}
{articles.length > 1 && (
<Reveal delay={200}>
<ul className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{articles.slice(1).map((a) => (
<li key={a.id}>
<a href={a.href ?? '#'} className="flex h-full min-h-14 items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-[13.5px] text-foreground transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0">
<Icon name="book-open" size={16} className="shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block leading-snug">{a.title}</span>
{a.meta && <span className="block text-[11.5px] text-muted-foreground">{a.meta}</span>}
</span>
</a>
</li>
))}
</ul>
</Reveal>
)}
{vote === 'no' && (
<div className="cwg-up mt-4 flex flex-col gap-3 rounded-2xl border border-dashed border-border-strong p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[14px] font-medium text-foreground">{t.stillHelp}</p>
<a href="#" className="inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground hover:bg-accent sm:h-9">
<Icon name="mail" size={15} />
{t.contact}
</a>
</div>
)}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/**
* A chat widget for websites: the launcher with a help home, a conversation
* with an AI note and quick replies, the handoff to a person with queue,
* a lead form inside the chat and an AI answer embedded in a help page.
*/
export function ChatWidget({ variant = 'launcher', labels, ...props }: ChatWidgetProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const body = (() => {
switch (variant) {
case 'conversation':
return <Conversation {...p} />;
case 'handoff':
return <Handoff {...p} />;
case 'lead-capture':
return <LeadCapture {...p} />;
case 'help-embed':
return <HelpEmbed {...p} />;
default:
return <LauncherHome {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Frequent questions
How do I install Chat widget?
With aiw add @ai-website/chat-widget. The command puts components/ui/chat-widget.tsx into your project, the code is yours from then on. It brings along icon and reveal. It needs no further npm packages.
Which variants does Chat widget have?
5 variants, Launcher and home, Conversation, Handoff to a person, Lead form in chat and Answer on the help page. You pick one with the variant prop, for example variant="conversation".
Can I translate the texts of Chat widget?
Yes. Chat widget carries 40 texts of its own in English and German, such as “Open chat”, “Close chat” and “Write a message”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Chat widget?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Chat widget with aiw_components and install it with aiw_add @ai-website/chat-widget. Name the variant in your prompt, for example variant="launcher", and the assistant writes your content straight into the props.
Can I adjust Chat widget without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ChatWidget code to copy.