Skip to content
UI Kit
Accent color

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

5 variants

Variants

Launcher and home

A round button in the corner with a short teaser. Opening shows a greeting, a question field, quick actions and popular articles, phones get a full sheet.

Open in Studio

Conversation

The open chat with a clear note that an AI answers, sources under answers, quick replies and a typing indicator. Enter sends.

Open in Studio

Handoff to a person

The bot hands over, the queue counts down as it scrolls into view, then the person joins with the chat so far. Email is offered instead of waiting.

Open in Studio

Lead form in chat

The bot asks for name and email inside the conversation, with consent and a link to the privacy policy. Errors say what to fix.

Open in Studio

Answer on the help page

Search that answers. An AI answer from the help articles sits right under the field, with a link to the article, a short check and the way to the team.

Open in Studio

Details

What Chat widget brings along, read from the registry and the source.

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
In collections
AI product

Props

The ChatWidgetProps interface from components/ui/chat-widget.tsx, with the comments from the code.

  • variantoptional
    ChatWidgetVariant

    launcherconversationhandofflead-capturehelp-embed

  • namerequired
    string

    Name of the assistant, e.g. "Lumen Help"

  • taglineoptional
    string

    Short line under the name, e.g. "Answers right away"

  • greetingoptional
    string
  • actionsoptional
    WidgetAction[]
  • articlesoptional
    WidgetArticle[]
  • messagesoptional
    WidgetMessage[]
  • suggestionsoptional
    string[]

    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

  • questionoptional
    string

    help-embed: the question already asked and its answer

  • answeroptional
    string
  • defaultOpenoptional
    boolean

    Start open

  • positionoptional
    'fixed' | 'contained'

    contained keeps the widget inside its parent (for previews), fixed pins it to the window

  • backdropoptional
    React.ReactNode

    contained: 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
openEnglish Open chatGerman Chat öffnen
closeEnglish Close chatGerman Chat schließen
messageEnglish Write a messageGerman Nachricht schreiben
sendEnglish SendGerman Senden
onlineEnglish Answers right awayGerman Antwortet sofort
aiNoteEnglish 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.
typingEnglish {name} is typingGerman {name} schreibt
askEnglish Ask a questionGerman Frage stellen
popularEnglish Popular articlesGerman Beliebte Artikel
teaserEnglish Hi, can I help you find something?German Hallo, kann ich dir beim Finden helfen?
dismissEnglish DismissGerman Ausblenden
fallbackEnglish 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.
queueEnglish You are number {n} in lineGerman Du bist Nummer {n} in der Warteschlange
waitEnglish Usually {wait}German Meist {wait}
joinedEnglish {name} joined the chatGerman {name} ist dem Chat beigetreten
nowEnglish nowGerman jetzt
leftEnglish Leave the queueGerman Warteschlange verlassen
emailInsteadEnglish Get the answer by email insteadGerman Antwort lieber per E-Mail
emailAskEnglish Write your email address below and the team replies there.German Schreib unten deine E-Mail-Adresse, dann antwortet das Team dort.
leftNoteEnglish You left the queue. The assistant keeps answering here.German Du hast die Warteschlange verlassen. Der Assistent antwortet hier weiter.
leadTitleEnglish Where can we reach you?German Wie erreichen wir dich?
leadNoteEnglish So the team can answer if you close this window.German Damit das Team antworten kann, auch wenn du das Fenster schließt.
nameEnglish NameGerman Name
emailEnglish EmailGerman E-Mail
consentEnglish I agree that my details are used to answer this request.German Ich bin einverstanden, dass meine Angaben für diese Anfrage genutzt werden.
privacyEnglish Privacy policyGerman Datenschutzerklärung
submitEnglish Send detailsGerman Angaben senden
leadDoneEnglish Thanks, {name}. We will reply to {email}.German Danke, {name}. Wir antworten an {email}.
invalidEmailEnglish Enter an email like name@example.comGerman Gib eine E-Mail wie name@beispiel.de ein
requiredEnglish Fill in name and emailGerman Name und E-Mail ausfüllen
searchHelpEnglish Search help or ask a questionGerman Hilfe durchsuchen oder Frage stellen
stillHelpEnglish Still need help?German Noch Fragen offen?
contactEnglish Write to the teamGerman Dem Team schreiben
helpfulEnglish Did this answer your question?German Hat das deine Frage beantwortet?
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
thanksEnglish Thanks for the feedback.German Danke für die Rückmeldung.
answerFromEnglish Answer from {name}German Antwort von {name}
readMoreEnglish Read the full articleGerman Ganzen Artikel lesen
poweredNoteEnglish 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
components/ui/chat-widget.tsx
'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.

Often searched

Jump to

Copied to the clipboard