# Dialog box

> A game’s dialog box: a dark speech bubble with a name tab, typewriter text with teal and red keywords, and a bouncing arrow to continue.

Where a game talks to the player. A line types out letter by letter in a rounded dark bubble, the speaker’s name riding its top edge, with things to remember in teal and warnings in red. A press lands the rest of the line at once; once it is out, a bouncing arrow says the next press continues. The same box takes four shapes: speech, a square journal page, narration with no box at all, and a system message that lights blue circuits and glows (“Lookout Tower activated.”). It keeps its own dark plate, so it reads on a light page and a dark one.

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

Category: Games · Dialogs.

## Anatomy

1. **Press area**: The whole box is one real button named by what a press does now (Skip, Continue, Open the dialog again); click, tap, Space, Enter or A.
2. **Plate**: The dark pill, 481 × 97 units with a 48.5-unit radius, in the kit’s colours on a light page and a dark one.
3. **Name tab**: Who speaks, in 14-unit italic riding the top edge 41.5 units in; on a light page it gets a small dark tab of its own.
4. **Side bracket**: A thin arc inside each end, split by a small tick: speech’s mark (a journal has straight lines).
5. **Line**: The words in 18-unit bold italic, every letter laid out from the start so nothing reflows as it types.
6. **Keyword**: `{braces}` in the text make a teal word, `[brackets]` a red one; each glows once when its last letter lands.
7. **Continue arrow**: A 20 × 12 outlined triangle round a solid one, half over the bottom edge; it pops in when the line is out and bobs.
8. **Key chips**: Space and A in small chips under the box, with what a press does now; they follow the page’s scheme.

## When to use

- A character talking to the player one line at a time, with their name on the box and the player setting the pace.
- Story beats the player must read before playing on: a press shows the rest of the line, the next press continues.
- Words that carry something to remember, such as an item, a name or a place, marked in teal or red inside the line.
- A found note or journal page, a narrator over the scene, or the world itself speaking (“Lookout Tower activated.”), in the same box with a different shape.
- Tutorial and quest text in a game, where a typing line and a bouncing arrow are what players expect.

## When not to use

- A question with answers to pick. Put a choice list next to the box after the line.
- A short notice that should not stop play, such as an item picked up. Use a game toast.
- A confirmation that blocks the game until answered, such as quitting without saving. Use a game modal.
- Help or long text the player reads at their own speed. Use a page or a quest log; typing slows reading down.
- Chat or messages in a product UI. Use a message list; the typewriter and game styling do not belong there.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 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 { DialogBox, type DialogLine } from './dialog-box';

const script: DialogLine[] = [
  { speaker: 'Odessa', text: 'Bring me three {river pearls} and meet me by the [forge].' },
  { variant: 'journal', speaker: 'Field Journal', text: 'The key is under the third stone. {-T}' },
  { variant: 'narration', text: 'The wind turns, and the bells go quiet.' },
  { variant: 'system', text: 'Lookout Tower activated.' },
];

export function Talk({ onDone }: { onDone: () => void }) {
  return <DialogBox script={script} speed={40} loop={false} autoplay={false} onEnd={onDone} />;
}
```

## States

- **Typing**: `defaultState="typing"`: half the line is out and the rest waits, already laid out. A press shows it all.
- **Line out**: The whole line is out, the keywords are lit and the arrow bobs: a press continues.
- **Journal**: `variant: 'journal'`: a square-cornered plate with straight side marks and the document’s title on the tab.
- **Narration**: `variant: 'narration'`: no box and no name, the words on a soft dark halo so they read on any page.
- **System**: `variant: 'system'`: the pill with blue circuits in both ends and glowing blue words, centred.
- **Line break**: A line break in the text is kept, for a short verse or a list read aloud.
- **Closed**: `defaultState="closed"`: after the last line the box is gone and the chips say Again; with `loop`, a press opens it on the first line.
- **Narrow**: Under 480 px wide the unit shrinks to 0.78 px and the words move closer to the ends, so the line keeps a fair measure.
- **Light**: `colorScheme="light"`: the box keeps its dark plate; the name gets a dark tab and the chips go dark on light (drawn here on a light page).
- **Dark**: `colorScheme="dark"`: the name sits bare with a shadow, the plate gets a faint light edge, the chips go light (drawn here on a dark page).

## How it works

- Every size is a multiple of one design unit, --u, set from the component’s width with a container query unit (between 0.78 px and 1.25 px, or --dialog-unit when set), so the box keeps the kit’s proportions: a 481 × 97 pill with a 48.5-unit radius (4-unit corners for a journal), 18-unit bold italic type on 22.7-unit lines inset 80 units from the ends, a 14-unit italic name crossing the top edge 41.5 units in, and a 20 × 12 arrow half over the bottom edge.
- A line is plain text with {braces} for teal keywords and [brackets] for red ones. It is split into letters once, and every letter is laid out from the start, invisible until its turn, so words never jump to the next line as they arrive. A timer reveals one letter at a time at `speed` letters per second; after a comma it holds five beats, after the end of a sentence nine (an ellipsis only at its last dot). Each letter fades in over 70 ms, and a keyword glows once when its last letter lands.
- A press while typing shows the whole line; a press when it is out continues. If the next line has the same kind and speaker, only the words fade (130 ms) and the next line types in the same box; otherwise the box shrinks away (190 ms) and the new one pops in, keyed afresh, with a 280 ms overshoot from 0.94 to 1.015, its name sliding in 90 ms later. After the last line the box closes, and a press opens it again on the first.
- The continue arrow is an SVG: an outlined triangle round a small solid one. It pops in with an overshoot when the line is out, bobs 3 units down and back every second, and dips away on the press.
- Speech lines have thin arcs inside both ends and a journal has straight marks, each split by a small tick. A system line draws circuit traces into both ends with stroke-dashoffset (staggered over half a second), pops its nodes in and flashes them light blue, and gives its words a blue glow that swells in 700 ms and then breathes.
- Narration has no box: a soft dark radial halo and a dark glow round each letter keep it readable on any page. On a light page the speaker’s name also gets a small dark tab, so the part that hangs over the page stays legible; on a dark page it is the kit’s bare name with a shadow.
- Uncontrolled, a demo plays the script while it is on screen and untouched: it reads each finished line for a moment (one second plus 22 ms a letter) and continues, skips once halfway through the second line, and opens the box again after it closes. Any real press hands it to the reader for a few seconds. Typing waits while the box is off screen or the tab hidden.

## API reference

- **script** (`DialogLine[]`, default `an invented exchange`): The lines, in order: { variant?, speaker?, text }. variant is speech (default), journal, narration or system; speaker is the name or the journal’s title on the tab; in text, {braces} mark a teal keyword and [brackets] a red one, and line breaks are kept.
- **speed** (`number`, default `38`): Letters per second while typing (system lines go 1.5 times faster). Commas and sentence ends add their own pauses.
- **loop** (`boolean`, default `true`): After the last line closes the box, a press opens it again on the first line.
- **onLineChange** (`(index: number) => void`): Called when a line starts typing, with its index in the script.
- **onEnd** (`() => void`): Called when the box closes after the last line.
- **defaultLine** (`number`, default `0`): Opens on this line of the script instead of the first, with the demo off.
- **defaultState** (`'typing' | 'done' | 'closed'`): Opens still in this state, with the demo off: typing holds part of the line out until a press, done has the whole line out with the arrow bobbing, closed has the box closed after the end.
- **defaultTyped** (`number`, default `0.5`): With defaultState="typing": the share of the line already out, from 0 to 1.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, plays the script on its own: reads, continues, skips once, starts again. Any press stops it for a while.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The box keeps its colours on both; focus, key chips, the name tab and the narration halo follow it. Set --dialog-unit on the root to fix the size (1px is the kit’s 481 × 97).
- **className** (`string`): Added to the root.

## Accessibility

- The box is one real button, named by what a press does now: “Skip” while the line types, “Continue” once it is out, “Open the dialog again” after the end. Space and Enter press it, and so does the A key while it has focus; it shows a :focus-visible ring.
- The drawn box is aria-hidden; a polite live region reads each whole line once when it starts, with its speaker (“Odessa: If you bring me…”), never letter by letter, and says when the dialog closes.
- Keywords are marked by colour, as in the kit, and add no meaning a screen reader would miss: the live region reads the line as plain text.
- Reduced motion: whole lines appear at once, with no pop, bob, glow swell, circuit draw or letter fades. The demo never plays.

## Source

### dialog-box/dialog-box.tsx

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

'use client';

import {
  Fragment,
  useEffect,
  useEffectEvent,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './dialog-box.module.css';

/*
 * Dialog box: where a game talks to the player. A line of a script types out in a dark box; a
 * press lands the rest of the line at once, and once the line is out a bouncing arrow says the
 * next press continues.
 *
 * - Four kinds of line, told apart by shape: speech (a rounded pill with thin side brackets and
 *   the speaker's name on a tab riding its top edge), journal (a square-cornered plate with
 *   straight side marks, its title on the tab), narration (no box, the words alone, on a soft dark
 *   halo of their own so they read on any page) and system (the pill with glowing blue circuit
 *   ornaments and glowing blue words: the world speaking, "… activated.").
 * - Keywords: in a line's text, {braces} mark a teal word (something to remember: an item, a
 *   name) and [brackets] a red one (a warning, a place, a count). A keyword brightens once when it
 *   has finished typing. A line break in the text is kept.
 * - Typing: every letter is laid out from the start, invisible until its turn, so words never jump
 *   to the next line as they arrive. Letters come at `speed` per second and fade in over a few
 *   frames; a comma holds the stream a little, the end of a sentence longer.
 * - Presses: a click or tap on the box, Space or Enter on it, or the A key while it has focus.
 *   While typing, a press is "skip"; when the line is out, "continue": the arrow dips, the words
 *   fade, and the next line types in the same box, or the box swaps (a new speaker or kind pops
 *   in after the old one shrinks away). After the last line the box closes; a press opens it again.
 * - The box pops in with a short overshoot and its name tab slides in after it; a system line
 *   draws its circuits in, lights them, and its words' glow swells and then breathes.
 * - Uncontrolled by the reader, a demo plays the script: it reads each line for a moment and
 *   continues, skips once mid-line, and starts again after the box closes. Any real press hands it
 *   to the reader. Typing waits while the box is off screen or the tab hidden; with reduced motion
 *   whole lines appear at once and nothing bounces.
 * - `defaultLine` and `defaultState` open it still on a given line and moment (part typed, all
 *   out, closed), with the demo off; a press carries on from there.
 * - The box keeps the kit's dark plate and light type on a light page and a dark one; what sits
 *   on the page itself (focus, the key chips) follows the colour scheme.
 */

export type DialogVariant = 'speech' | 'journal' | 'narration' | 'system';

export type DialogLine = {
  /** The kind of line; speech by default. */
  variant?: DialogVariant;
  /** Who speaks (speech) or the document's title (journal), on the tab over the box. */
  speaker?: string;
  /** The words: {teal keyword}, [red keyword]; line breaks are kept. */
  text: string;
};

export type DialogBoxProps = {
  /** The lines, in order. */
  script?: DialogLine[];
  /** Letters per second while typing. */
  speed?: number;
  /** After the last line, a press opens the box again on the first. */
  loop?: boolean;
  /** Called when a line starts typing, with its index. */
  onLineChange?: (index: number) => void;
  /** Called when the box closes after the last line. */
  onEnd?: () => void;
  /** Opens on this line of the script instead of the first; turns the demo off. */
  defaultLine?: number;
  /**
   * Opens still in this state, with the demo off: `typing` holds part of the line out (see
   * `defaultTyped`) until a press, `done` has the whole line out with the arrow, `closed` has the
   * box closed after the end.
   */
  defaultState?: 'typing' | 'done' | 'closed';
  /** With `defaultState="typing"`: the share of the line already out, 0 to 1 (0.5 by default). */
  defaultTyped?: number;
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

/** An invented exchange: a trade, a warning, a found note, a narrator and a system message. */
export const DEMO_SCRIPT: DialogLine[] = [
  {
    speaker: 'Odessa',
    text: 'If you bring me three {river pearls}, I’ll mend that {old lantern} for you. Leave them by the [forge].',
  },
  {
    speaker: 'Odessa',
    text: 'Mind the [east bridge] after dark. Only the {glowmoths} light it, and they sleep at midnight.',
  },
  {
    variant: 'journal',
    speaker: 'Tamsin’s Field Journal',
    text: 'I buried the spare key under the third stone past the old mill... I’ll write down where the rest are hidden. {-Tamsin}',
  },
  {
    variant: 'narration',
    text: 'In return for [two] {Ember Seeds}, I will steady your hand. So tell me what it is that you seek.',
  },
  { variant: 'system', text: 'Lookout Tower activated.' },
];

type Tone = 'plain' | 'key' | 'alert';
type Glyph = { char: string; tone: Tone; group: number };
type Phase = 'typing' | 'done' | 'leaving' | 'closed';

/** The text, letter by letter, each with its tone and its keyword (-1 for none). */
function parse(text: string) {
  const glyphs: Glyph[] = [];
  const pattern = /\{([^}]*)\}|\[([^\]]*)\]/g;
  let last = 0;
  let group = 0;
  const push = (part: string, tone: Tone, id: number) => {
    for (const char of part) glyphs.push({ char, tone, group: id });
  };
  for (const match of text.matchAll(pattern)) {
    push(text.slice(last, match.index), 'plain', -1);
    push(match[1] ?? match[2], match[1] !== undefined ? 'key' : 'alert', group++);
    last = match.index + match[0].length;
  }
  push(text.slice(last), 'plain', -1);
  // Where each keyword's last letter is, so it can brighten once it is all out.
  const ends: number[] = [];
  glyphs.forEach((glyph, index) => {
    if (glyph.group >= 0) ends[glyph.group] = index;
  });
  return { glyphs, ends, plain: glyphs.map((glyph) => glyph.char).join('') };
}

/** How long to hold after a letter, in letter beats: commas a little, sentence ends longer. */
function beats(glyphs: Glyph[], index: number) {
  const char = glyphs[index]?.char ?? '';
  const next = glyphs[index + 1]?.char ?? ' ';
  if (/[.!?…]/.test(char)) return /[.!?…]/.test(next) ? 1.6 : 9;
  if (/[,;:]/.test(char)) return 5;
  if (char === '\n') return 3;
  return 1;
}

const sameBox = (a?: DialogLine, b?: DialogLine) =>
  !!a && !!b && (a.variant ?? 'speech') === (b.variant ?? 'speech') && a.speaker === b.speaker;

/** The demo reads a full line for a while before it continues. */
const readTime = (letters: number) => Math.min(3800, 1000 + letters * 22);

export function DialogBox({
  script = DEMO_SCRIPT,
  speed = 38,
  loop = true,
  onLineChange,
  onEnd,
  defaultLine,
  defaultState,
  defaultTyped = 0.5,
  autoplay = true,
  colorScheme,
  className,
}: DialogBoxProps) {
  const { ref, take, visible, reduced, playing } = useAutoplay<HTMLDivElement>(7000);
  const locked = defaultLine !== undefined || defaultState !== undefined;
  const [index, setIndex] = useState(() =>
    Math.max(0, Math.min(script.length - 1, Math.floor(defaultLine ?? 0))),
  );
  const [shown, setShown] = useState(() => {
    if (defaultState !== 'typing' && defaultState !== 'done') return 0;
    const count = parse(script[index]?.text ?? '').glyphs.length;
    if (defaultState === 'done') return count;
    return Math.round(count * Math.max(0, Math.min(1, defaultTyped)));
  });
  const [phase, setPhase] = useState<Phase>(defaultState ?? 'typing');
  // A line opened mid-typing by defaultState holds there until the first press.
  const [held, setHeld] = useState(defaultState === 'typing');
  const [leaving, setLeaving] = useState<'text' | 'box' | null>(null);
  const [boxId, setBoxId] = useState(0);
  const [pressId, setPressId] = useState(0);
  const skipped = useRef(false);
  const timer = useRef(0);

  const line = script[Math.min(index, script.length - 1)] ?? { text: '' };
  const variant = line.variant ?? 'speech';
  const parsed = useMemo(() => parse(line.text), [line.text]);
  const total = parsed.glyphs.length;
  const demo = autoplay && !locked && playing;

  useEffect(() => () => window.clearTimeout(timer.current), []);

  const announce = useEffectEvent((at: number) => onLineChange?.(at));
  useEffect(() => {
    if (phase === 'typing') announce(index);
  }, [index, boxId, phase]);

  // Typing: one letter at a time while on screen; all at once under reduced motion.
  useEffect(() => {
    if (phase !== 'typing' || held) return;
    // The hook starts out assuming reduced motion until it has asked; ask here too, so the
    // first line still types.
    const still = reduced && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (still || shown >= total) {
      const id = window.setTimeout(() => {
        setShown(total);
        setPhase('done');
      }, 0);
      return () => window.clearTimeout(id);
    }
    if (!visible) return;
    const pace = (variant === 'system' ? 1.5 : 1) * speed;
    const delay = (1000 / pace) * (shown === 0 ? 6 : beats(parsed.glyphs, shown - 1));
    const id = window.setTimeout(() => setShown((count) => count + 1), delay);
    return () => window.clearTimeout(id);
  }, [phase, held, shown, total, visible, reduced, speed, variant, parsed]);

  const open = () => {
    window.clearTimeout(timer.current);
    skipped.current = false;
    setLeaving(null);
    setIndex(0);
    setShown(0);
    setBoxId((id) => id + 1);
    setPhase('typing');
  };

  const press = (byDemo = false) => {
    if (!byDemo) take();
    setHeld(false);
    setPressId((id) => id + 1);
    if (phase === 'closed') {
      if (loop || byDemo) open();
      return;
    }
    if (phase === 'leaving') return;
    if (phase === 'typing') {
      setShown(total);
      setPhase('done');
      return;
    }
    const next = index + 1;
    const swap = next >= script.length || !sameBox(script[index], script[next]);
    setLeaving(swap ? 'box' : 'text');
    setPhase('leaving');
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(
      () => {
        setLeaving(null);
        if (next >= script.length) {
          setPhase('closed');
          onEnd?.();
          return;
        }
        setIndex(next);
        setShown(0);
        if (swap) setBoxId((id) => id + 1);
        setPhase('typing');
      },
      reduced ? 0 : swap ? 190 : 140,
    );
  };

  // The demo: reads, continues, skips once mid-line, and opens the box again after it closes.
  const demoPress = useEffectEvent(() => press(true));
  useEffect(() => {
    if (!demo) return;
    if (phase === 'done') {
      const id = window.setTimeout(demoPress, readTime(total));
      return () => window.clearTimeout(id);
    }
    if (phase === 'closed') {
      const id = window.setTimeout(demoPress, 1400);
      return () => window.clearTimeout(id);
    }
    if (phase === 'typing' && index === 1 && !skipped.current && shown >= total * 0.45) {
      skipped.current = true;
      const id = window.setTimeout(demoPress, 0);
      return () => window.clearTimeout(id);
    }
  }, [demo, phase, index, shown, total]);

  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (event.key === 'a' || event.key === 'A') {
      event.preventDefault();
      press();
    }
  };

  const speaker = variant === 'speech' || variant === 'journal' ? line.speaker : undefined;
  const closed = phase === 'closed';
  const done = phase === 'done' || (phase === 'leaving' && leaving !== null);
  const action = closed ? 'Open the dialog again' : phase === 'typing' ? 'Skip' : 'Continue';

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-paused={visible ? undefined : ''}
    >
      <div className={styles.frame} data-part="frame">
        <button
          type="button"
          className={styles.press}
          onClick={() => press()}
          onKeyDown={onKeyDown}
          aria-label={action}
          data-part="press"
        >
          {!closed && (
            <span
              key={boxId}
              className={styles.box}
              data-variant={variant}
              data-leaving={leaving === 'box' ? '' : undefined}
              data-part="box"
              aria-hidden="true"
            >
              {variant !== 'narration' && <span className={styles.plate} data-part="plate" />}
              {variant === 'speech' && <Brackets kind="arc" />}
              {variant === 'journal' && <Brackets kind="line" />}
              {variant === 'system' && <Circuits />}
              {speaker && (
                <span className={styles.name} data-part="name">
                  {speaker}
                </span>
              )}
              <span
                key={index}
                className={styles.text}
                data-leaving={leaving ? '' : undefined}
                data-part="text"
              >
                <span data-part="words">
                  <Letters parsed={parsed} shown={shown} />
                </span>
              </span>
              <span
                className={styles.arrow}
                data-on={done ? '' : undefined}
                data-out={phase === 'leaving' ? '' : undefined}
                data-part="arrow"
              >
                <Arrow />
              </span>
            </span>
          )}
        </button>
      </div>
      <p className={styles.legend} aria-hidden="true" data-part="legend">
        <kbd key={`k${pressId}`} data-press={pressId ? '' : undefined}>
          Space
        </kbd>
        <kbd>A</kbd>
        <span>{closed ? 'Again' : phase === 'typing' ? 'Skip' : 'Next'}</span>
      </p>
      <p className={styles.live} aria-live="polite">
        {closed ? 'Dialog closed.' : `${line.speaker ? `${line.speaker}: ` : ''}${parsed.plain}`}
      </p>
    </div>
  );
}

/** The line's letters, laid out from the start, each visible once typed. */
function Letters({ parsed, shown }: { parsed: ReturnType<typeof parse>; shown: number }) {
  const words: ReactNode[] = [];
  let word: ReactNode[] = [];
  const flush = (key: number) => {
    if (word.length)
      words.push(
        <span key={`w${key}`} className={styles.word}>
          {word}
        </span>,
      );
    word = [];
  };
  parsed.glyphs.forEach((glyph, index) => {
    if (glyph.char === '\n') {
      flush(index);
      words.push(<br key={`b${index}`} />);
      return;
    }
    const on = index < shown;
    const lit = glyph.group >= 0 && parsed.ends[glyph.group] < shown;
    const letter = (
      <span
        key={index}
        className={styles.letter}
        data-tone={glyph.tone === 'plain' ? undefined : glyph.tone}
        data-on={on ? '' : undefined}
        data-lit={lit ? '' : undefined}
      >
        {glyph.char}
      </span>
    );
    if (glyph.char === ' ') {
      flush(index);
      words.push(<Fragment key={index}>{letter}</Fragment>);
    } else word.push(letter);
  });
  flush(parsed.glyphs.length);
  return <>{words}</>;
}

/** The continue arrow: an outlined triangle round a small solid one. */
function Arrow() {
  return (
    <svg viewBox="0 0 20 12" className={styles.arrowSvg}>
      <path className={styles.arrowBack} d="M1.2 1 H18.8 L10 11.2 Z" />
      <path className={styles.arrowCore} d="M6.6 3.7 H13.4 L10 7.9 Z" />
    </svg>
  );
}

/** Thin marks inside the box's ends: gentle arcs for speech, straight lines for a journal. */
function Brackets({ kind }: { kind: 'arc' | 'line' }) {
  const half =
    kind === 'arc' ? 'M7 1 Q3.4 8 2.3 15.2 M2.3 18.8 Q3.4 26 7 33' : 'M2.3 1 V13.6 M2.3 20.4 V33';
  return (
    <>
      {[0, 1].map((side) => (
        <svg
          key={side}
          viewBox="0 0 8 34"
          className={styles.bracket}
          data-kind={kind}
          data-side={side ? 'end' : 'start'}
          data-part="bracket"
        >
          <path d={half} />
          <path d="M2.3 16.2 V17.8" data-tick="" />
        </svg>
      ))}
    </>
  );
}

/** The system box's circuits: a trace along each end with lit nodes, drawn in, then lit. */
function Circuits() {
  return (
    <>
      {[0, 1].map((side) => (
        <svg
          key={side}
          viewBox="0 0 40 97"
          className={styles.circuit}
          data-side={side ? 'end' : 'start'}
          data-part="circuit"
        >
          <g className={styles.traces}>
            <path pathLength={1} d="M22.5 15.5 Q12 24 9.6 45" />
            <path pathLength={1} d="M13.1 48.5 H20.5" />
            <path pathLength={1} d="M9.6 52 V60 L15.2 62.6 L21 71.4 L26.8 74.6 V81.5" />
            <path pathLength={1} d="M19.6 69.4 L15.6 74.4" />
          </g>
          <g className={styles.nodes}>
            <circle cx="24.4" cy="12.6" r="2.9" />
            <circle cx="9.6" cy="48.5" r="3.5" />
            <circle cx="14.4" cy="77.2" r="2" />
            <circle cx="26.8" cy="84.6" r="3.1" />
          </g>
        </svg>
      ))}
    </>
  );
}
```

### dialog-box/dialog-box.module.css

```css
/* Dialog box (dialog-box.tsx): one line of a game's script in its box, alone on the page's stage,
   with the keys under it in small chips.

   Every size is a multiple of --u, one design unit (1 px at the kit's size): the root sets it from
   its own width (a container query unit), between 0.78 px and 1.25 px; --dialog-unit fixes it. At
   the kit's size the box is 481 × 97: a pill (radius 48.5) for speech and system lines, a plate
   with 4-unit corners for a journal, 18-unit bold italic type on 22.7-unit lines, a 14-unit name
   tab riding the top edge and a 20 × 12 continue arrow half over the bottom edge.

   Colours are the tokens below. The box keeps the kit's colours on any page, as a game's does: a
   dark plate, warm light type, teal and red keywords, a blue glow for system lines; narration,
   which has no box, brings a soft dark halo, and on a light page the name gets a small dark tab.
   Only what sits straight on the page (focus, the key chips, a faint edge round the plate on a
   dark page) follows the scheme.

   Motion: the box pops in with an overshoot and its name slides in after; letters fade in as they
   are typed; a finished keyword brightens once; the arrow pops in and bobs; leaving, the words fade
   and the box shrinks away. A system box draws its circuit traces in (stroke-dashoffset), lights
   its nodes, and its words' glow swells and breathes. */

.root {
  /* The box. */
  --plate: #33322e;
  --ink: #e9e1d1;
  --key: #6bdecc;
  --alert: #f15050;
  --mark: rgb(233 225 209 / 0.24);
  --mark-tick: rgb(233 225 209 / 0.5);
  --arrow: #e2ded3;
  --sys-ink: #baeffb;
  --sys-glow: rgb(80 196 255 / 0.75);
  --sys-glow-far: rgb(48 150 230 / 0.42);
  --sys-arrow: #2f6996;
  --circuit: #284f5f;
  --node: #29505f;
  --node-ring: #336679;
  --node-lit: #8be4ff;
  --halo: rgb(26 25 23 / 0.9);
  --halo-edge: rgb(26 25 23 / 0.55);
  --tab: var(--plate);
  --shade: rgb(18 17 15 / 0.92);
  /* On the stage itself. */
  --edge: rgb(255 255 255 / 0);
  --focus: #1b1a18;
  --chip: rgb(0 0 0 / 0.05);
  --chip-line: rgb(0 0 0 / 0.14);
  --chip-ink: rgb(0 0 0 / 0.62);
  --u: var(--dialog-unit, clamp(0.78px, calc((100cqi - 32px) / 481), 1.25px));
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 18px;
  box-sizing: border-box;
  width: 100%;
  padding: 24px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.1));
    /* On a light page, the name gets a small dark tab and narration a denser halo. */
    --tab: light-dark(var(--plate), transparent);
    --halo: light-dark(rgb(26 25 23 / 0.8), rgb(26 25 23 / 0.5));
    --halo-edge: light-dark(rgb(26 25 23 / 0.55), rgb(26 25 23 / 0.22));
    --focus: light-dark(#1b1a18, #fff);
    --chip: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --chip-line: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.16));
    --chip-ink: light-dark(rgb(0 0 0 / 0.62), rgb(255 255 255 / 0.7));
  }
}
/* 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;
}

.frame {
  width: min(calc(481 * var(--u)), 100%);
}

/* The whole box is the button: no chrome of its own. */
.press {
  display: grid;
  align-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: calc(123 * var(--u));
  margin: 0;
  padding: calc(14 * var(--u)) 0 calc(12 * var(--u));
  border: 0;
  border-radius: calc(24 * var(--u));
  background: none;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
  outline: none;
}
.press:focus-visible {
  box-shadow: 0 0 0 2px var(--focus);
}

/* ---- The box ---- */

.box {
  position: relative;
  display: grid;
  align-items: center;
  min-height: calc(97 * var(--u));
  transform-origin: 50% 100%;
  animation: box-in 280ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.box[data-leaving] {
  animation: box-out 190ms cubic-bezier(0.4, 0, 1, 1) both;
}
@keyframes box-in {
  from {
    opacity: 0;
    scale: 0.94;
  }
  65% {
    opacity: 1;
    scale: 1.015;
  }
  to {
    scale: 1;
  }
}
@keyframes box-out {
  to {
    opacity: 0;
    scale: 0.96;
  }
}

.plate {
  position: absolute;
  inset: 0;
  border-radius: calc(48.5 * var(--u));
  background: var(--plate);
  box-shadow: 0 0 0 1px var(--edge);
}
.box[data-variant='journal'] .plate {
  border-radius: calc(4 * var(--u));
}
/* Narration has no box: a wide, soft scrim and a dark glow round each letter keep the words
   readable on any page, the way subtitles sit over a bright scene. */
.box[data-variant='narration']::before {
  content: '';
  position: absolute;
  inset: calc(-34 * var(--u)) calc(64 * var(--u)) calc(-34 * var(--u)) calc(14 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--halo) 34%, var(--halo-edge) 66%, transparent);
}

/* ---- The words ---- */

.text {
  position: relative;
  display: block;
  padding: calc(14 * var(--u)) calc(80 * var(--u));
  color: var(--ink);
  font-size: calc(18 * var(--u));
  font-style: italic;
  font-weight: 600;
  line-height: calc(22.7 * var(--u));
  overflow-wrap: break-word;
  transition: opacity 130ms linear;
}
.text[data-leaving] {
  opacity: 0;
}
.box[data-variant='journal'] .text {
  padding-inline: calc(46 * var(--u)) calc(43 * var(--u));
}
.box[data-variant='narration'] .text {
  box-sizing: border-box;
  width: calc(384.5 * var(--u));
  max-width: 100%;
  padding-inline: calc(58.5 * var(--u)) 0;
  text-shadow:
    0 0 calc(2 * var(--u)) var(--shade),
    0 calc(1 * var(--u)) calc(6 * var(--u)) rgb(18 17 15 / 0.6);
}
.box[data-variant='system'] .text {
  padding-inline: calc(56 * var(--u));
  color: var(--sys-ink);
  text-align: center;
  animation:
    sys-glow-in 700ms ease-out both,
    sys-breathe 2600ms 700ms ease-in-out infinite alternate;
}
@keyframes sys-glow-in {
  from {
    text-shadow:
      0 0 0 transparent,
      0 0 0 transparent;
  }
  55% {
    text-shadow:
      0 0 calc(7 * var(--u)) var(--sys-glow),
      0 0 calc(16 * var(--u)) var(--sys-glow-far);
  }
  to {
    text-shadow:
      0 0 calc(4 * var(--u)) var(--sys-glow),
      0 0 calc(10 * var(--u)) var(--sys-glow-far);
  }
}
@keyframes sys-breathe {
  from {
    text-shadow:
      0 0 calc(4 * var(--u)) var(--sys-glow),
      0 0 calc(10 * var(--u)) var(--sys-glow-far);
  }
  to {
    text-shadow:
      0 0 calc(5 * var(--u)) var(--sys-glow),
      0 0 calc(14 * var(--u)) var(--sys-glow-far);
  }
}

.word {
  white-space: nowrap;
}
/* Every letter is laid out from the start, so nothing reflows as the line types. */
.letter {
  opacity: 0;
  transition: opacity 70ms linear;
}
.letter[data-on] {
  opacity: 1;
}
.letter[data-tone='key'] {
  color: var(--key);
}
.letter[data-tone='alert'] {
  color: var(--alert);
}
.letter[data-lit] {
  animation: key-lit 560ms ease-out;
}
@keyframes key-lit {
  35% {
    text-shadow: 0 0 calc(7 * var(--u)) currentColor;
  }
}

/* ---- The name tab: riding the top edge, with a dark halo where it hangs over the page ---- */

.name {
  position: absolute;
  top: calc(-8.6 * var(--u));
  left: calc(41.5 * var(--u));
  max-width: calc(100% - 60 * var(--u));
  overflow: hidden;
  color: var(--ink);
  font-size: calc(14 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(17 * var(--u));
  white-space: nowrap;
  text-overflow: ellipsis;
  margin-inline: calc(-7 * var(--u));
  padding-inline: calc(7 * var(--u));
  border-radius: calc(8.5 * var(--u)) calc(8.5 * var(--u)) 0 0;
  background: var(--tab);
  text-shadow:
    0 0 calc(1.5 * var(--u)) var(--shade),
    0 0 calc(4 * var(--u)) rgb(18 17 15 / 0.55),
    0 calc(1 * var(--u)) calc(1 * var(--u)) rgb(0 0 0 / 0.5);
  animation: name-in 260ms 90ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.box[data-variant='journal'] .name {
  left: calc(23.5 * var(--u));
}
@keyframes name-in {
  from {
    opacity: 0;
    translate: calc(-8 * var(--u)) 0;
  }
}

/* ---- Marks inside the ends ---- */

.bracket {
  position: absolute;
  top: 50%;
  width: calc(8 * var(--u));
  height: calc(34 * var(--u));
  margin-top: calc(-17 * var(--u));
  overflow: visible;
  fill: none;
  stroke: var(--mark);
  stroke-width: 1.3;
  stroke-linecap: round;
}
.bracket[data-kind='arc'][data-side='start'] {
  left: calc(9.4 * var(--u));
}
.bracket[data-kind='arc'][data-side='end'] {
  right: calc(9.4 * var(--u));
  scale: -1 1;
}
.bracket[data-kind='line'] {
  height: calc(32 * var(--u));
  margin-top: calc(-16 * var(--u));
}
.bracket[data-kind='line'][data-side='start'] {
  left: calc(10.2 * var(--u));
}
.bracket[data-kind='line'][data-side='end'] {
  right: calc(10.2 * var(--u));
  scale: -1 1;
}
.bracket path[data-tick] {
  stroke: var(--mark-tick);
}

/* ---- The system box's circuits ---- */

.circuit {
  position: absolute;
  top: 50%;
  width: calc(40 * var(--u));
  height: calc(97 * var(--u));
  margin-top: calc(-48.5 * var(--u));
  overflow: visible;
}
.circuit[data-side='start'] {
  left: 0;
}
.circuit[data-side='end'] {
  right: 0;
  scale: -1 1;
}
.traces path {
  fill: none;
  stroke: var(--circuit);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  animation: trace-in 520ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.traces path:nth-child(1) {
  animation-delay: 120ms;
}
.traces path:nth-child(2) {
  animation-delay: 260ms;
}
.traces path:nth-child(3) {
  animation-delay: 300ms;
}
.traces path:nth-child(4) {
  animation-delay: 460ms;
}
@keyframes trace-in {
  from {
    stroke-dashoffset: 1;
  }
  to {
    stroke-dashoffset: 0;
  }
}
.nodes circle {
  fill: var(--node);
  stroke: var(--node-ring);
  stroke-width: 1.2;
  transform-box: fill-box;
  transform-origin: center;
  animation:
    node-in 240ms cubic-bezier(0.3, 1.6, 0.5, 1) both,
    node-lit 700ms ease-out both;
}
.nodes circle:nth-child(1) {
  animation-delay: 140ms, 640ms;
}
.nodes circle:nth-child(2) {
  animation-delay: 300ms, 700ms;
}
.nodes circle:nth-child(3) {
  animation-delay: 520ms, 760ms;
}
.nodes circle:nth-child(4) {
  animation-delay: 620ms, 820ms;
}
@keyframes node-in {
  from {
    opacity: 0;
    scale: 0.2;
  }
}
@keyframes node-lit {
  30% {
    fill: var(--node-lit);
    stroke: var(--node-lit);
  }
}

/* ---- The continue arrow: half over the bottom edge, centred ---- */

.arrow {
  position: absolute;
  bottom: calc(-7 * var(--u));
  left: calc(50% - 10 * var(--u));
  width: calc(20 * var(--u));
  height: calc(12 * var(--u));
  opacity: 0;
  transform-origin: 50% 0;
}
.box[data-variant='narration'] .arrow {
  bottom: calc(-10 * var(--u));
  left: calc(min(221.5 * var(--u), 50% + 48.5 * var(--u)) - 10 * var(--u));
}
.arrow[data-on] {
  opacity: 1;
  animation:
    arrow-in 220ms cubic-bezier(0.3, 1.6, 0.5, 1) both,
    arrow-bob 1000ms 220ms ease-in-out infinite;
}
.arrow[data-out] {
  animation: arrow-out 150ms ease-in both;
}
@keyframes arrow-in {
  from {
    opacity: 0;
    scale: 0.3;
  }
}
@keyframes arrow-bob {
  50% {
    translate: 0 calc(3 * var(--u));
  }
}
@keyframes arrow-out {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    translate: 0 calc(4 * var(--u));
  }
}
.arrowSvg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.arrowBack {
  fill: none;
  stroke: var(--arrow);
  stroke-width: 1.7;
  stroke-linejoin: round;
}
.arrowCore {
  fill: var(--arrow);
}
.box[data-variant='narration'] .arrowSvg {
  filter: drop-shadow(0 0 calc(2 * var(--u)) var(--shade));
}
.box[data-variant='system'] .arrowBack {
  fill: var(--sys-arrow);
  stroke: var(--sys-ink);
}
.box[data-variant='system'] .arrowCore {
  fill: var(--sys-ink);
}
.box[data-variant='system'] .arrowSvg {
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--sys-glow));
}

/* Off screen or in a hidden tab, nothing keeps moving. */
.root[data-paused] .arrow,
.root[data-paused] .text {
  animation-play-state: paused;
}

/* ---- Narrow stages: tighter insets so the words keep a fair measure ---- */

@container (max-width: 479px) {
  .press {
    min-height: calc(150 * var(--u));
  }
  .text {
    padding-inline: calc(46 * var(--u));
  }
  .box[data-variant='journal'] .text {
    padding-inline: calc(30 * var(--u));
  }
  .box[data-variant='system'] .text {
    padding-inline: calc(40 * var(--u));
  }
  .box[data-variant='narration'] .text {
    padding-inline: calc(28 * var(--u)) calc(12 * var(--u));
  }
  .box[data-variant='narration'] .arrow {
    left: calc(50% - 10 * var(--u));
  }
  .box[data-variant='narration']::before {
    inset: calc(-26 * var(--u)) calc(-6 * var(--u));
  }
}

/* ---- The keys, as small chips under the box, and the live region ---- */

.legend {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  color: var(--chip-ink);
  font-size: 12px;
  line-height: 1;
  pointer-events: none;
}
.legend kbd {
  display: inline-block;
  min-width: 1.9em;
  box-sizing: border-box;
  padding: 0.4em 0.45em;
  border: 1px solid var(--chip-line);
  border-radius: 6px;
  background: var(--chip);
  font: inherit;
  font-weight: 600;
  text-align: center;
}
.legend kbd[data-press] {
  animation: key-dip 180ms ease-out;
}
@keyframes key-dip {
  35% {
    translate: 0 2px;
  }
}
.legend span {
  margin-left: 2px;
  font-weight: 500;
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .box,
  .box[data-leaving],
  .name,
  .arrow[data-on],
  .arrow[data-out],
  .letter[data-lit],
  .traces path,
  .nodes circle,
  .legend kbd[data-press] {
    animation: none;
  }
  .box[data-variant='system'] .text {
    animation: none;
    text-shadow:
      0 0 calc(4 * var(--u)) var(--sys-glow),
      0 0 calc(10 * var(--u)) var(--sys-glow-far);
  }
  .letter,
  .text {
    transition: none;
  }
}
```

### dialog-box/use-autoplay.ts

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

'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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```