Chat-Widget
Ein Support-Chat für Websites mit einem Starter samt Hilfe-Start, einem Gespräch mit KI-Hinweis und Schnellantworten, der Übergabe an einen Menschen mit Warteschlange, einem Kontaktformular im Chat und einer KI-Antwort direkt auf der Hilfeseite.
Eine von 2 Components in KI-Assistenten
Variants
Details
Was Chat-Widget mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Assistenten
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/chat-widget.tsx
- Bringt mit
- utilskiticonreveal
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 40 eigene Texte
- In Sammlungen
- KI-Produkt
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ChatWidgetProps aus components/ui/chat-widget.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalChatWidgetVariantlauncherconversationhandofflead-capturehelp-embed
namePflichtstringName 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
Eingebaute Texte
Diese Texte schreibt Chat-Widget selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 40 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| open | Englisch Open chat | Deutsch Chat öffnen |
| close | Englisch Close chat | Deutsch Chat schließen |
| message | Englisch Write a message | Deutsch Nachricht schreiben |
| send | Englisch Send | Deutsch Senden |
| online | Englisch Answers right away | Deutsch Antwortet sofort |
| aiNote | Englisch Answers come from an AI and can be wrong. A person takes over on request. | Deutsch Antworten kommen von einer KI und können falsch sein. Auf Wunsch übernimmt ein Mensch. |
| typing | Englisch {name} is typing | Deutsch {name} schreibt |
| ask | Englisch Ask a question | Deutsch Frage stellen |
| popular | Englisch Popular articles | Deutsch Beliebte Artikel |
| teaser | Englisch Hi, can I help you find something? | Deutsch Hallo, kann ich dir beim Finden helfen? |
| dismiss | Englisch Dismiss | Deutsch Ausblenden |
| fallback | Englisch Thanks for your message. I found two articles that may help, and a person from the team reads along if you need more. | Deutsch 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 | Englisch You are number {n} in line | Deutsch Du bist Nummer {n} in der Warteschlange |
| wait | Englisch Usually {wait} | Deutsch Meist {wait} |
| joined | Englisch {name} joined the chat | Deutsch {name} ist dem Chat beigetreten |
| now | Englisch now | Deutsch jetzt |
| left | Englisch Leave the queue | Deutsch Warteschlange verlassen |
| emailInstead | Englisch Get the answer by email instead | Deutsch Antwort lieber per E-Mail |
| emailAsk | Englisch Write your email address below and the team replies there. | Deutsch Schreib unten deine E-Mail-Adresse, dann antwortet das Team dort. |
| leftNote | Englisch You left the queue. The assistant keeps answering here. | Deutsch Du hast die Warteschlange verlassen. Der Assistent antwortet hier weiter. |
| leadTitle | Englisch Where can we reach you? | Deutsch Wie erreichen wir dich? |
| leadNote | Englisch So the team can answer if you close this window. | Deutsch Damit das Team antworten kann, auch wenn du das Fenster schließt. |
| name | Englisch Name | Deutsch Name |
| Englisch Email | Deutsch E-Mail | |
| consent | Englisch I agree that my details are used to answer this request. | Deutsch Ich bin einverstanden, dass meine Angaben für diese Anfrage genutzt werden. |
| privacy | Englisch Privacy policy | Deutsch Datenschutzerklärung |
| submit | Englisch Send details | Deutsch Angaben senden |
| leadDone | Englisch Thanks, {name}. We will reply to {email}. | Deutsch Danke, {name}. Wir antworten an {email}. |
| invalidEmail | Englisch Enter an email like name@example.com | Deutsch Gib eine E-Mail wie name@beispiel.de ein |
| required | Englisch Fill in name and email | Deutsch Name und E-Mail ausfüllen |
| searchHelp | Englisch Search help or ask a question | Deutsch Hilfe durchsuchen oder Frage stellen |
| stillHelp | Englisch Still need help? | Deutsch Noch Fragen offen? |
| contact | Englisch Write to the team | Deutsch Dem Team schreiben |
| helpful | Englisch Did this answer your question? | Deutsch Hat das deine Frage beantwortet? |
| yes | Englisch Yes | Deutsch Ja |
| no | Englisch No | Deutsch Nein |
| thanks | Englisch Thanks for the feedback. | Deutsch Danke für die Rückmeldung. |
| answerFrom | Englisch Answer from {name} | Deutsch Antwort von {name} |
| readMore | Englisch Read the full article | Deutsch Ganzen Artikel lesen |
| poweredNote | Englisch Generated from our help articles. Check details in the article. | Deutsch Aus unseren Hilfeartikeln erstellt. Details stehen im Artikel. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Chat-Widget reagiert selbst auf die Tasten Escape, Enter und Leertaste.
- Setzt aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, dialog, search und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 848 Zeilen
'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}
</>
);
}Häufige Fragen
Wie installiere ich Chat-Widget?
Mit aiw add @ai-website/chat-widget. Der Befehl legt die Datei components/ui/chat-widget.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und reveal. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Chat-Widget?
5 Varianten, Starter mit Startseite, Gespräch, Übergabe an einen Menschen, Kontaktformular im Chat und Antwort auf der Hilfeseite. Du wählst sie mit der Prop variant, zum Beispiel variant="conversation".
Kann ich die Texte von Chat-Widget übersetzen?
Ja. Chat-Widget bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Chat öffnen“, „Chat schließen“ und „Nachricht schreiben“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Chat-Widget einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Chat-Widget mit aiw_components und installieren sie mit aiw_add @ai-website/chat-widget. Nenn im Prompt die Variante, etwa variant="launcher", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Chat-Widget ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ChatWidget-Code zum Kopieren.