# Chat panel

> A small assistant panel: tabs across its head, replies that stream in under a reasoning line, and a composer at its foot.

A compact chat panel for a side of an app. Tabs and three actions sit across its head; the conversation is the reader’s message in a bubble, then the assistant’s replies, each a reasoning line (what it did, from what, for how long) over what it found. Send a message and the replies stream in one by one, each rising into place, the latest a touch out of focus until it is done.

A React component by Bruno Delorence: https://brunodelorence.com/components/chat-panel. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Panel**: A white card on a quiet stage, 380 px wide at most and 288 px tall; the head and composer stay, the conversation scrolls.
2. **Tabs**: One per conversation. The picked one sits on a filled chip, the others at half strength; arrows, Home and End move between them.
3. **Actions**: New chat, History and More, as 24 px icon buttons; each calls `onAction` with its name.
4. **Message**: What the reader sent, in a filled bubble on the right. Sending a new one replaces the conversation.
5. **Reasoning line**: What the assistant did, in stronger words, what it worked from, muted, and how long it took.
6. **Reply**: What it found. It rises 8 px and fades in over 400 ms, half a pixel out of focus until it is done.
7. **Composer**: A filled box whose edge darkens while it has focus. Enter sends.
8. **Send**: Grey and disabled while the composer is empty; filled with the ink colour as soon as there is something to send.

## When to use

- An assistant docked beside the work: a side panel or a corner of a dashboard, 380 px wide at most.
- Short exchanges where each answer shows what the assistant did (`Sales History`, `Flavor Data`, for 4s) before what it found.
- Answers that arrive in steps: each reply streams in on its own and sharpens once it is done.
- A few parallel conversations, one per subject, switched by the tabs across the head.

## When not to use

- A full-page chat with a long history. Use a full-height thread with a richer composer.
- Answers that need tables, charts or long documents: the 288 px panel has no room for them.
- One question with a known set of answers. Use an approval card or a form.
- Notifications or status from a background job. Use a toast or an agent progress view.

## Installation

Install it with the shadcn CLI, into components/chat-panel of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/chat-panel.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { ChatPanel, type ChatReply } from './chat-panel';

export function FlavorAssistant({ ask }: { ask: (text: string) => ChatReply[] }) {
  return (
    <ChatPanel
      autoplay={false}
      tabs={['Flavors', 'Suppliers']}
      prompt="Compare mint chip to last summer"
      respond={ask}
      onSend={(text) => console.log('sent', text)}
      placeholder="Prompt or tag a flavor with @"
    />
  );
}
```

## States

- **Idle**: A finished conversation and an empty composer: the send button is grey and disabled.
- **Focused**: The composer’s edge darkens in 150 ms; the prompt shows no ring, only its caret.
- **Ready to send**: Something in the composer: the send button fills with the ink colour in 200 ms.
- **Sent, waiting**: The message shows at once and the composer empties; the first reply comes 545 ms later.
- **Streaming**: Each next reply comes 1375 ms after the last, still out of focus; it sharpens 1200 ms later.
- **Another tab**: The second tab picked. The conversation stays; the host swaps it on `onTabChange`.

## How it works

- Sending replaces the conversation: the message shows at once, the composer empties and the send button greys out. The replies then arrive on a clock, the first 545 ms later and each next one 1375 ms after the one before, so they read as work coming in rather than a page loading.
- A reply rises 8 px and fades in over 400 ms on cubic-bezier(0.23, 1, 0.32, 1). Every reply after the first arrives still being worked on, blurred by half a pixel, and sharpens over 400 ms 1200 ms later; the log is aria-busy until the last one has settled.
- Each conversation has a key that every send bumps, and the replies are keyed by it, so a new conversation plays its replies in again while a settled one never re-animates.
- The tabs are a real tablist with roving focus: the picked one sits on a filled chip, the others at half strength (three quarters under the pointer), crossfading in 100 ms. The conversation stays; a host swaps it on onTabChange.
- The composer is a form, so Enter sends. Its edge darkens while it has focus (150 ms); the send button fills with the ink colour as soon as there is something to send (200 ms) and gives to 0.96 under the press. A press on the composer’s padding focuses the prompt.
- The panel is 288 px tall at most 380 px wide; head and foot are fixed and the conversation scrolls between them, following each new reply into view.
- While on screen and untouched, the demo types a question into the composer, sends it, lets the replies stream in, holds, and asks the next one. Any input hands it to the reader, with what the demo had typed cleared.
- Every colour is a light-dark() token on the root, so the panel follows the page’s color-scheme or the colorScheme prop.

## API reference

- **tabs** (`string[]`, default `['Flavors', 'Suppliers']`): The tabs across the head.
- **tab** (`number`): The picked tab’s index; with it, the tabs are controlled (pair it with onTabChange).
- **defaultTab** (`number`, default `0`): The tab picked first, when uncontrolled.
- **onTabChange** (`(index: number) => void`): Called with the index of the tab picked.
- **prompt** (`string`, default `'Compare mint chip to last summer'`): The conversation’s opening message.
- **replies** (`ChatReply[]`): The replies shown under it, complete: each a tool, a source, a time in seconds and the text it found.
- **respond** (`(text: string) => ChatReply[]`): The replies that stream in after a message is sent; the opening replies by default.
- **defaultState** (`ChatPanelState`): Opens in a given state, with no demo: draft (words in the composer), arrived and settled (how many opening replies are in, and in focus), focused (the composer drawn focused) and pressed (the send button held).
- **onSend** (`(text: string) => void`): Called with each message sent.
- **onAction** (`(action: 'new' | 'history' | 'more') => void`): Called when one of the head’s actions is pressed.
- **placeholder** (`string`, default `'Prompt or tag a flavor with @'`): The composer’s placeholder.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the panel types a question, sends it and streams the replies in by itself.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The tabs are a tablist with one tab stop: arrow keys, Home and End move between them, and the conversation is their tabpanel.
- The conversation is a polite log: new replies are announced as they arrive, and it is aria-busy while one is still being worked on. It can be focused and scrolled from the keyboard.
- The head’s actions are buttons named New chat, History and More; the icons are decorative.
- The composer is a form with a labelled prompt, so Enter sends; the send button is disabled while there is nothing to send. The prompt shows its focus by the composer’s darker edge and its caret; every other control has a visible focus ring.
- Under reduced motion the demo never plays, and replies, tabs and buttons change without animation.

## Source

### chat-panel/chat-panel.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './chat-panel.module.css';

/*
 * Chat panel: a small assistant panel. Tabs and three actions across its head, the conversation
 * in the middle (the reader's message in a bubble on the right, then the assistant's replies,
 * each a reasoning line, "Sales History  Flavor Data  for 4s", over what it found) and a composer
 * at its foot.
 *
 * How it moves
 * - Sending replaces the conversation: the message shows at once, the composer empties and its
 *   send button greys out. The replies then stream in: the first 545 ms later, each next one
 *   1375 ms after the one before. A reply rises 8 px and fades in over 400 ms on a steep
 *   ease-out. Every reply after the first arrives still being worked on, a half-pixel out of
 *   focus, and sharpens over 400 ms 1200 ms later.
 * - Tabs: the picked one sits on a filled chip, the others at half strength (three quarters
 *   under the pointer), crossfading in 100 ms. The conversation stays; the host can swap it on
 *   onTabChange.
 * - The composer's edge darkens while it has focus (150 ms); the send button fills with the ink
 *   colour as soon as there is something to send (200 ms) and gives a little under the press.
 * - The demo, while on screen and untouched, types a question into the composer, sends it and
 *   lets the replies stream in, then holds and asks the next one. Any input hands it to the
 *   reader, with whatever the demo had typed cleared.
 * - defaultState opens it with words in the composer, the composer focused, the send button
 *   held, or the opening replies part-way in, and with no demo: for documentation and tests.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type ChatReply = {
  /** What the assistant did, in the reasoning line's first, stronger words. */
  tool: string;
  /** What it worked from, in the line's muted words. */
  source: string;
  /** How long it took, in seconds: "for 4s". */
  seconds: number;
  /** What it found. */
  text: string;
};

/** Where the panel opens (defaultState): for documentation and tests. */
export type ChatPanelState = {
  /** Words already in the composer. */
  draft?: string;
  /** How many of the opening replies have come in (all by default); the rest never come. */
  arrived?: number;
  /** How many of those are settled (all that came by default); the others stay out of focus. */
  settled?: number;
  /** Draws the composer as focused (its darker edge), without taking the page's focus. */
  focused?: boolean;
  /** Draws the send button held down. */
  pressed?: boolean;
};

const SAMPLE_REPLIES: ChatReply[] = [
  {
    tool: 'Sales History',
    source: 'Flavor Data',
    seconds: 4,
    text: 'Pulled 3 summers of mint chip sales for comparison.',
  },
  {
    tool: 'Comparison',
    source: 'Trend Detection',
    seconds: 2,
    text: 'Mint chip is up 12% with stronger weekend peaks.',
  },
];
const SAMPLE_PROMPT = 'Compare mint chip to last summer';
/** What the demo asks, in turn. */
const DEMO_PROMPTS = ['How is mint chip doing this summer?', SAMPLE_PROMPT];

/** The streaming beats, in ms: the first reply, the gap to each next one, and how long one
 *  stays out of focus. */
const FIRST = 545;
const GAP = 1375;
const SETTLE = 1200;
/** The demo's beats, in ms: before typing, per character, before sending, after the last reply. */
const DEMO = { idle: 1400, char: 42, aim: 520, hold: 2600 } as const;

type Thread = {
  prompt: string;
  replies: ChatReply[];
  /** How many replies have come in. */
  arrived: number;
  /** How many of those are settled; the others are still being worked on. */
  settled: number;
  /** Bumped by each send, so the replies of a new conversation play in again. */
  key: number;
};

export function ChatPanel({
  tabs = ['Flavors', 'Suppliers'],
  tab: controlledTab,
  defaultTab = 0,
  onTabChange,
  prompt = SAMPLE_PROMPT,
  replies = SAMPLE_REPLIES,
  respond,
  onSend,
  onAction,
  placeholder = 'Prompt or tag a flavor with @',
  autoplay = true,
  defaultState,
  colorScheme,
  className = '',
}: {
  /** The tabs across the head. */
  tabs?: string[];
  /** The picked tab's index; with it, the tabs are controlled (pair it with onTabChange). */
  tab?: number;
  /** The tab picked first, when uncontrolled. */
  defaultTab?: number;
  /** Called with the index of the tab picked. */
  onTabChange?: (index: number) => void;
  /** The conversation's opening message. */
  prompt?: string;
  /** The replies shown under it, complete. */
  replies?: ChatReply[];
  /** The replies that stream in after a message is sent; the opening replies by default. */
  respond?: (text: string) => ChatReply[];
  /** Called with each message sent. */
  onSend?: (text: string) => void;
  /** Called when one of the head's actions is pressed. */
  onAction?: (action: 'new' | 'history' | 'more') => void;
  /** The composer's placeholder. */
  placeholder?: string;
  /** While on screen and untouched, the panel types, sends and streams replies by itself. */
  autoplay?: boolean;
  /** Opens it in a given state, with no demo: for documentation and tests. */
  defaultState?: ChatPanelState;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>();
  const id = useId();
  const [ownTab, setOwnTab] = useState(defaultTab);
  const tab = controlledTab ?? ownTab;
  const [draft, setDraft] = useState(defaultState?.draft ?? '');
  const [pressed, setPressed] = useState(defaultState?.pressed ?? false);
  const [thread, setThread] = useState<Thread>(() => {
    const arrived = Math.min(replies.length, Math.max(0, defaultState?.arrived ?? replies.length));
    const settled = Math.min(arrived, Math.max(0, defaultState?.settled ?? arrived));
    return { prompt, replies, arrived, settled, key: 0 };
  });
  const timers = useRef<number[]>([]);
  const list = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  /** Whether the draft is the demo's typing, to clear when the reader takes over. */
  const demoTyped = useRef(false);
  const respondRef = useRef(respond);
  const repliesRef = useRef(replies);
  useEffect(() => {
    respondRef.current = respond;
    repliesRef.current = replies;
  });

  const clearTimers = () => {
    for (const t of timers.current) window.clearTimeout(t);
    timers.current = [];
  };
  useEffect(() => clearTimers, []);

  // Sending: the message at once, then the replies on their beats.
  const send = useCallback(
    (text: string) => {
      const message = text.trim();
      if (!message) return;
      clearTimers();
      const coming = respondRef.current?.(message) ?? repliesRef.current;
      setThread((now) => ({
        prompt: message,
        replies: coming,
        arrived: 0,
        settled: 0,
        key: now.key + 1,
      }));
      setDraft('');
      onSend?.(message);
      coming.forEach((_, index) => {
        const at = FIRST + index * GAP;
        timers.current.push(
          window.setTimeout(
            () =>
              setThread((now) => ({
                ...now,
                arrived: index + 1,
                settled: index === 0 ? 1 : now.settled,
              })),
            at,
          ),
        );
        if (index > 0)
          timers.current.push(
            window.setTimeout(
              () => setThread((now) => ({ ...now, settled: Math.max(now.settled, index + 1) })),
              at + SETTLE,
            ),
          );
      });
    },
    [onSend],
  );

  // A reply came in: keep the latest in view when the conversation outgrows the panel. The
  // opening conversation shows from its top.
  useEffect(() => {
    const element = list.current;
    if (!element || thread.key === 0 || thread.arrived === 0) return;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    element.scrollTo({ top: element.scrollHeight, behavior: reduced ? 'auto' : 'smooth' });
  }, [thread.arrived, thread.key]);

  // The demo: type a question, send it, let the replies stream in, hold, and the next one.
  const demo = autoplay && !defaultState && playing;
  useEffect(() => {
    if (!demo) return;
    let cancelled = false;
    const ids: number[] = [];
    const wait = (ms: number) =>
      new Promise<void>((resolve) => ids.push(window.setTimeout(resolve, ms)));
    let lap = 0;
    (async () => {
      while (!cancelled) {
        const question = DEMO_PROMPTS[lap++ % DEMO_PROMPTS.length];
        await wait(DEMO.idle);
        for (let i = 1; i <= question.length && !cancelled; i++) {
          demoTyped.current = true;
          setDraft(question.slice(0, i));
          await wait(DEMO.char);
        }
        await wait(DEMO.aim);
        if (cancelled) return;
        setPressed(true);
        await wait(140);
        if (cancelled) return;
        setPressed(false);
        demoTyped.current = false;
        send(question);
        const count = (respondRef.current?.(question) ?? repliesRef.current).length;
        await wait(FIRST + Math.max(0, count - 1) * GAP + SETTLE + DEMO.hold);
      }
    })();
    return () => {
      cancelled = true;
      for (const t of ids) window.clearTimeout(t);
      setPressed(false);
      if (demoTyped.current) {
        demoTyped.current = false;
        setDraft('');
      }
    };
  }, [demo, send]);

  const pick = (index: number) => {
    if (controlledTab === undefined) setOwnTab(index);
    onTabChange?.(index);
  };
  const onTabKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    const last = tabs.length - 1;
    const next =
      event.key === 'ArrowRight'
        ? index === last
          ? 0
          : index + 1
        : event.key === 'ArrowLeft'
          ? index === 0
            ? last
            : index - 1
          : event.key === 'Home'
            ? 0
            : event.key === 'End'
              ? last
              : -1;
    if (next < 0) return;
    event.preventDefault();
    pick(next);
    tabRefs.current[next]?.focus();
  };
  const submit = (event: FormEvent) => {
    event.preventDefault();
    send(draft);
    // The send button goes disabled as the composer empties: the focus goes back to the field.
    input.current?.focus({ preventScroll: true });
  };

  const busy = thread.settled < thread.replies.length;
  const ready = draft.trim().length > 0;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <section className={styles.panel} aria-label="Assistant" data-part="panel">
        <div className={styles.head} data-part="head">
          <div className={styles.tabs} data-part="tabs" role="tablist" aria-label="Conversations">
            {tabs.map((label, index) => (
              <button
                key={label}
                ref={(element) => {
                  tabRefs.current[index] = element;
                }}
                type="button"
                role="tab"
                id={`${id}-tab-${index}`}
                aria-selected={index === tab}
                aria-controls={`${id}-panel`}
                tabIndex={index === tab ? 0 : -1}
                className={styles.tab}
                data-part="tab"
                onClick={() => pick(index)}
                onKeyDown={(event) => onTabKey(event, index)}
              >
                {label}
              </button>
            ))}
          </div>
          <div className={styles.actions} data-part="actions">
            <button
              type="button"
              className={styles.action}
              data-part="action"
              aria-label="New chat"
              onClick={() => onAction?.('new')}
            >
              <PlusIcon />
            </button>
            <button
              type="button"
              className={styles.action}
              data-part="action"
              aria-label="History"
              onClick={() => onAction?.('history')}
            >
              <ClockIcon />
            </button>
            <button
              type="button"
              className={styles.action}
              data-part="action"
              aria-label="More"
              onClick={() => onAction?.('more')}
            >
              <DotsIcon />
            </button>
          </div>
        </div>

        <div
          ref={list}
          className={styles.list}
          data-part="conversation"
          role="tabpanel"
          id={`${id}-panel`}
          aria-labelledby={`${id}-tab-${tab}`}
          tabIndex={0}
        >
          <div className={styles.log} role="log" aria-live="polite" aria-busy={busy}>
            <div className={styles.mine}>
              <p className={styles.bubble} data-part="message">
                {thread.prompt}
              </p>
            </div>
            {thread.replies.slice(0, thread.arrived).map((reply, index) => (
              <div
                key={`${thread.key}-${index}`}
                className={styles.reply}
                data-part="reply"
                data-pending={index >= thread.settled ? '' : undefined}
              >
                <p className={styles.reasoning} data-part="reasoning">
                  <span className={styles.tool}>{reply.tool}</span>
                  <span className={styles.source}>{reply.source}</span>
                  <span>for {reply.seconds}s</span>
                </p>
                <p className={styles.text} data-part="reply-text">
                  {reply.text}
                </p>
              </div>
            ))}
          </div>
        </div>

        <div className={styles.foot}>
          <form
            className={styles.composer}
            data-part="composer"
            data-focused={defaultState?.focused ? '' : undefined}
            onSubmit={submit}
            onPointerDown={(event) => {
              if (event.target === event.currentTarget) {
                event.preventDefault();
                input.current?.focus();
              }
            }}
          >
            <input
              ref={input}
              className={styles.input}
              data-part="input"
              value={draft}
              placeholder={placeholder}
              aria-label="Chat prompt"
              onChange={(event) => {
                demoTyped.current = false;
                setDraft(event.target.value);
              }}
            />
            <div className={styles.row}>
              <button
                type="submit"
                className={styles.send}
                data-part="send"
                aria-label="Send"
                disabled={!ready}
                data-pressed={pressed ? '' : undefined}
              >
                <ArrowIcon />
              </button>
            </div>
          </form>
        </div>
      </section>
    </div>
  );
}

/* The icons, drawn on a 24-unit grid with round 2-unit strokes. */
function PlusIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      aria-hidden="true"
      className={styles.icon}
      strokeWidth="2"
    >
      <path d="M5 12h14M12 5v14" />
    </svg>
  );
}
function ClockIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      aria-hidden="true"
      className={styles.icon}
      strokeWidth="2"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M12 7.2V12l3 2" />
    </svg>
  );
}
function DotsIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      aria-hidden="true"
      className={styles.glyph}
      fill="currentColor"
    >
      <circle cx="5" cy="12" r="1.8" />
      <circle cx="12" cy="12" r="1.8" />
      <circle cx="19" cy="12" r="1.8" />
    </svg>
  );
}
function ArrowIcon() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      aria-hidden="true"
      className={styles.icon}
      strokeWidth="2.4"
    >
      <path d="M5 12l7-7 7 7M12 5v14" />
    </svg>
  );
}
```

### chat-panel/chat-panel.module.css

```css
/* Chat panel (chat-panel.tsx): a quiet stage with a small assistant panel in its middle, tabs and
   actions across its head, the conversation, and the composer at its foot. At night the stage is
   near-black and the panel a shade lighter; by day the stage is a pale grey and the panel white.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Memory. What moves is opacity, a transform and a half-pixel blur on a reply as it comes in, and
   a few colours; nothing runs while the conversation is still. */

.root {
  /* The words: messages and replies, the reasoning line's muted words, the icons and placeholder. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* The stage, the panel, and the fills: the picked tab, the message, the composer. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --field: oklch(96.1% 0.001 286.375);
  --hover: oklch(97% 0.002 247.839);
  /* Edges: the stage's and the head's rule, the composer's (darker with focus), the idle send. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* The panel's lift: a ring, then a tall soft shadow by day, a short dark one at night. */
  --card-ring: var(--line);
  --card-day: rgb(0 0 0 / 0.03);
  --card-day-2: rgb(0 0 0 / 0.02);
  --card-day-3: rgb(0 0 0 / 0.01);
  --card-night: transparent;
  --focus: #387bff;
  /* The replies' ease: fast out of the gate, a long soft landing; and the controls' ease. */
  --ease: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-ui: cubic-bezier(0.4, 0, 0.2, 1);
  position: relative;
  container-type: inline-size;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 312px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --card-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --card-day: light-dark(rgb(0 0 0 / 0.03), transparent);
    --card-day-2: light-dark(rgb(0 0 0 / 0.02), transparent);
    --card-day-3: light-dark(rgb(0 0 0 / 0.01), transparent);
    --card-night: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --focus: light-dark(#387bff, #6ea0ff);
  }
}

/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) button {
  padding: 0;
  margin: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  cursor: pointer;
}
:where(.root) p {
  margin: 0;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The panel: 380 px wide at most, 288 px tall, its head and foot fixed and the conversation
   scrolling between them. */
.panel {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 380px;
  height: 288px;
  overflow: hidden;
  border-radius: 14px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-ring),
    0 1px 2px var(--card-night),
    0 2px 6px var(--card-night),
    0 18px 47px var(--card-day),
    0 7.5px 19px var(--card-day-2),
    0 4px 10.5px var(--card-day-2),
    0 2.3px 5.8px var(--card-day-3),
    0 1.2px 3.1px var(--card-day-3),
    0 0.5px 1.3px var(--card-day-3);
}

/* The head: tabs on the left, actions on the right, a rule under them. */
.head {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px;
  border-bottom: 1px solid var(--line);
}
.tabs {
  display: flex;
  align-items: center;
  min-width: 0;
}
.tab {
  padding: 3px 8px;
  border-radius: 6px;
  font-size: 13px;
  line-height: 19.5px;
  white-space: nowrap;
  color: var(--ink);
  opacity: 0.5;
  transition:
    background-color 100ms var(--ease-ui),
    opacity 100ms var(--ease-ui);
}
.tab[aria-selected='true'] {
  background: var(--field);
  opacity: 1;
}
@media (hover: hover) {
  .tab[aria-selected='false']:hover {
    opacity: 0.75;
  }
}
.actions {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: 4px;
}
.action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 6px;
  color: var(--ink-3);
  transition:
    background-color 100ms var(--ease-ui),
    color 100ms var(--ease-ui);
}
@media (hover: hover) {
  .action:hover {
    background: var(--hover);
    color: var(--ink-2);
  }
}
.icon {
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* Icons keep their size whatever a host page says about svg. */
.root .icon,
.root .glyph {
  flex: none;
  width: 15px;
  height: 15px;
}
.root .send .icon {
  width: 16px;
  height: 16px;
}

/* The conversation. */
.list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 10px 12px 4px;
  scrollbar-width: none;
}
.list::-webkit-scrollbar {
  display: none;
}
.list:focus-visible {
  outline-offset: -2px;
}
.log {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.mine {
  display: flex;
  justify-content: flex-end;
  padding-left: 56px;
}
.bubble {
  padding: 6px 12px;
  border-radius: 12px;
  background: var(--field);
  font-size: 13px;
  line-height: 1.4;
  overflow-wrap: anywhere;
}

/* A reply rises 8 px and fades in; while still being worked on, it is a half-pixel out of focus. */
.reply {
  display: flex;
  flex-direction: column;
  gap: 6px;
  filter: blur(0);
  animation: rise 400ms var(--ease) both;
  transition: filter 400ms var(--ease);
}
.reply[data-pending] {
  filter: blur(0.5px);
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}
.reasoning {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  column-gap: 4px;
  font-size: 12px;
  line-height: 1.3;
}
.tool {
  font-weight: 500;
}
.source {
  color: var(--ink-2);
}
.text {
  font-size: 13px;
  line-height: 1.5;
}

/* The composer: the prompt over a row with the send button, on a filled box whose edge darkens
   while it has focus. */
.foot {
  flex-shrink: 0;
  margin-top: auto;
  padding: 6px;
}
.composer {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--field);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.035);
  cursor: text;
  transition:
    border-color 150ms var(--ease-ui),
    box-shadow 150ms var(--ease-ui);
}
.composer:focus-within,
.composer[data-focused] {
  border-color: var(--line-strong);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.025);
}
.input {
  width: 100%;
  min-width: 0;
  min-height: 18px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  line-height: 1.4;
  letter-spacing: inherit;
  outline: none;
}
/* The prompt shows its focus by the composer's darker edge and its caret, not a ring. */
.root .input:focus-visible {
  outline: none;
}
.input::placeholder {
  color: var(--ink-3);
  opacity: 1;
}
.row {
  display: flex;
  align-items: center;
  justify-content: flex-end;
}
.send {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 8px;
  background: var(--ink);
  color: var(--surface);
  transition:
    background-color 200ms var(--ease-ui),
    color 200ms var(--ease-ui),
    transform 200ms var(--ease-ui);
}
.send:disabled {
  background: var(--line-strong);
  color: var(--ink-2);
  cursor: default;
}
.send:enabled:active,
.send[data-pressed] {
  transform: scale(0.96);
}

@media (prefers-reduced-motion: reduce) {
  .reply {
    animation: none;
    transition: none;
  }
  .tab,
  .action,
  .composer,
  .send {
    transition: none;
  }
}
```

### chat-panel/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```