# Title cards

> A game’s big serif title cards: a place, a landmark, a named enemy, a boss and a quest banner with a COMPLETE stamp, each revealed with gathering letters.

The titles a game lays across the middle of the screen when the player crosses a threshold. A location’s name glows (cool for a sanctum, warm for a region) over a subline between scroll ornaments; a landmark reads plain under its region; a named enemy gets a red rule; a boss’s epithet comes first, then its name slams in, white with a red edge and glow, and the card shakes. A quest shows its kind’s icon over a pale band, and completing it drops a COMPLETE stamp onto the band. Titles gather from wide letter-spacing as they fade in. Pick a card from the chips, or complete the quest.

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

Category: Games · Dialogs.

## Anatomy

1. **Plate**: A warm dark room, 13 em by 3.8 em of the title size (60 px on a wide stage, 26 px on a phone), lit a little at its centre; cards come and go inside it without moving the chips.
2. **Quest kind**: The quest’s icon and label (Main Quest, Side Quest, Trial) in small italic sans; the icon pops in first.
3. **Band**: A pale band that opens from the centre and stretches across the card, carrying the quest’s title in dark ink.
4. **Title**: Cinzel capitals and small capitals, never wrapped, gathering in from wide letter-spacing as they fade in.
5. **Stamp**: COMPLETE, dropped from large onto the band’s lower right with a flash of glow when `completed` turns on.
6. **Card chips**: One chip per card under the plate (`aria-pressed` on the shown one); picking the shown one replays it.
7. **Complete chip**: Toggles the quest’s stamp; from any other card it brings the quest in already completed.

## When to use

- Marking a threshold the player just crossed: a named place entered, a landmark found, a boss fight begun.
- A one-off announcement laid across the middle of the screen that the player reads in a second and that leaves on its own.
- Giving events a ranked weight: a location loudest, a landmark plainer, an enemy smaller with a red rule, a boss with a slam and a shake.
- Opening and closing quests, with a stamp that drops onto the quest’s band when it is completed.
- A cutscene or trailer beat that needs a cinematic title over the picture (`bare` drops the plate and chips).

## When not to use

- Routine feedback such as an item picked up or a save done. Use game toasts in a corner.
- Several messages that may arrive together: a game never stacks two title cards. Use a toast queue or a quest log.
- Text the player must act on or answer. Use a dialog box or a game modal.
- Tracking objectives over time. Use a quest log; this card announces a quest, it does not list its steps.
- Product headings or page titles outside a game. Use the page’s own heading styles.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/title-cards.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 { TitleCards, type TitleCard } from './title-cards';

const CARDS: TitleCard[] = [
  { id: 'harbor', kind: 'location', tone: 'warm', title: 'Saltmere Harbor' },
  { id: 'warden', kind: 'boss', title: 'The Iron Warden', kicker: 'Keeper of the Lower Gate' },
  { id: 'gate', kind: 'quest', quest: 'main', title: 'Open the Lower Gate' },
];

export function Announcer({ card, done }: { card: string; done: boolean }) {
  return <TitleCards bare cards={CARDS} card={card} completed={done} />;
}
```

## States

- **Location, cool**: `tone="cool"`: the largest title, white with a blue edge and glow, its subline between two scroll ornaments.
- **Location, warm**: `tone="warm"`: a smaller cream title with a warm glow and no subline.
- **Landmark**: Plain cream, no glow, under its region between two leaf ornaments.
- **Enemy**: A smaller name under its region, with a red rule drawn from the centre.
- **Boss**: The epithet first, then the name slams in, white with a dark red edge and glow; the card shakes once.
- **Quest**: A main quest’s icon and label over its title on the pale band.
- **Quest complete**: `completed`: the band dims a touch and the COMPLETE stamp drops onto it.
- **Side quest and trial**: `quest="side"` and `quest="trial"` change the icon and the label; `questLabel` names it otherwise.
- **Bare**: `bare`: the card alone at 60 px, no plate or chips, to lay over a game’s own picture.
- **Phone width**: At 375 px the title size follows the width (about 28 px), the plate grows taller and the chips tighten; the card scales as one.
- **Light**: `colorScheme="light"`: a soft shadow under the plate and pale chips. The card’s own colours never change.
- **Dark**: `colorScheme="dark"`: a brighter plate edge, a deeper shadow and dark chips.

## How it works

- A card is a layer. Showing another card, or the same one again, sends the shown layer out (it fades in 260 ms) and mounts a new one in the same grid cell; the newcomer’s parts wait --wait (260 ms when something is leaving), so two titles never stack, as in a game.
- Each part plays its keyframes as it mounts. A title gathers: it fades in while its letter-spacing and its text-indent shrink together from 0.42 em to 0.02 em (the indent balances the space after the last letter, so the line stays centred). A location’s glow then swells past its resting text-shadow and settles; its subline rises in while the scroll ornaments slide outwards.
- An enemy’s red rule sits in a grid with the name and reaches 0.75 em past it on each side; it draws from the centre (scale from 0 on x) once the name has landed. A boss’s epithet fades in first; the name slams from 1.32× with wide spacing on an overshooting curve, its red glow flares, and the whole card shakes for 320 ms.
- A quest’s icon pops, its label fades, the band opens from the centre and its title gathers on it. The COMPLETE stamp mounts when completed is set: it drops from 2.6× to just under its size and settles, with a flash of warm glow; when the quest arrives already completed, the stamp waits for the band.
- The titles are set in Cinzel, a free inscriptional serif whose lowercase letters are small capitals, loaded once from Google Fonts (a stylesheet link the component adds to the document), with Georgia behind it. The boss’s and the sanctum’s edges are a text stroke painted under the fill (paint-order), and every glow is a text- or box-shadow, so the letters stay sharp.
- Every size is in em of the plate’s font size (60 px wide, down to 26 px on a phone through a container query, or --title-size), so a card scales as one; small text never goes under 11 px. The cards sit on their own warm dark plate, lit a little at its centre, so their colours stay the same on a light or a dark page; only the plate’s edge, its shadow and the chips follow the colour scheme. bare draws a card alone, to lay over a game.
- Uncontrolled, chips under the plate pick a card (the shown one replays) and complete the quest, and a demo plays a short run of play (a sanctum, a region, a landmark, an enemy, a boss, a quest and its completion) while it is on screen and untouched; any input hands it to the reader.

## API reference

- **cards** (`TitleCard[]`, default `sanctum, region, landmark, enemy, boss, quest`): The cards, each { id, kind, title, kicker?, tone?, quest?, questLabel?, label? }. kind is 'location' | 'landmark' | 'enemy' | 'boss' | 'quest'; kicker is a location's subline, a landmark's or an enemy's region, or a boss's epithet; tone ('cool' | 'warm') sets a location's glow; quest ('main' | 'side' | 'trial') picks a quest's icon; label names its chip.
- **card** (`string`): The id of the card shown, controlled: changing it plays the next card in. The demo is off, and the chips ask onCardChange.
- **defaultCard** (`string`, default `'sanctum'`): Uncontrolled, the card it starts with.
- **onCardChange** (`(card: string) => void`): Asked when a chip is picked (the shown card’s chip replays it).
- **completed** (`boolean`): Whether a quest card is completed, controlled: turning it on drops the COMPLETE stamp onto the band.
- **onCompletedChange** (`(completed: boolean) => void`): Asked when the Complete chip is toggled.
- **bare** (`boolean`, default `false`): The card alone, without its plate and chips, to lay over a game. Its titles are 60 px, or --title-size.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play: each card in turn, then the quest completed. Uncontrolled only.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the plate’s edge and shadow and the chips; by default it follows the page. The cards keep their colours.
- **className** (`string`): Added to the root.

## Accessibility

- The titles, kickers and labels are real text; the ornaments, the enemy’s rule and the quest icons are aria-hidden.
- A polite live region reads each new card (“Mire Stalker. Brackwood Marsh.”) and a completed quest (“Main Quest complete: The Drowned Archive.”). A leaving card is hidden from assistive technology at once.
- The chips are toggle buttons (aria-pressed) in a labelled group, reachable by Tab, with a :focus-visible ring; pressing the shown card’s chip replays it.
- Small text never goes under 11 px, and every colour pair keeps its contrast on the plate, whatever the page’s colour scheme.
- Reduced motion: no gathering, slams, shakes, pops, draws or drops; each part only fades in for 200 ms. The demo never plays.

## Source

### title-cards/title-cards.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { LeafDash, QuestIcon, ScrollEnd, type QuestKind } from './ornaments';
import { useAutoplay } from './use-autoplay';
import styles from './title-cards.module.css';

/*
 * Title cards: the big serif titles a game lays across the middle of the screen when the player
 * crosses a threshold. Five kinds, each with its own weight:
 *
 * - location: you entered a named place. The largest title, glowing (cool for a sanctum, warm for a
 *   region), with an optional subline between two scroll ornaments (its keeper, its trial).
 * - landmark: you found a point of interest. Plain cream, no glow, under a small kicker naming the
 *   region, between two leaf ornaments.
 * - enemy: a named enemy noticed you. Smaller, with a red rule under it that says threat.
 * - boss: the fight starts. The epithet reads first; then the name slams in, white with a dark red
 *   edge and a red glow, and the card shakes once.
 * - quest: a quest began. Its kind's icon and label, then its title on a pale band. When it is
 *   completed, a COMPLETE stamp drops onto the band.
 *
 * How it moves: a card is a layer. A new card (or a replay) sends the shown layer out (it fades,
 * 260 ms) and mounts a new one, whose parts play keyframes on mount, a little later if something
 * was leaving: a game never stacks two title cards. Titles gather from wide letter-spacing as they
 * fade in (letter-spacing and text-indent animate together, so the line stays centred), then their
 * glow swells. Kickers fade while their ornaments slide outwards; the enemy's rule draws from the
 * centre; the band opens from the centre. The stamp mounts when the quest is completed and plays
 * its drop (after the band if both arrive together).
 *
 * The titles are set in Cinzel (a free inscriptional serif, loaded once from Google Fonts, falling
 * back to the system's serif). The cards sit on their own dark plate, so they read on a light page
 * or a dark one; `bare` draws them alone, to lay over a game. Uncontrolled, chips under the plate
 * pick a card (the shown one replays) and a demo plays a short run of play while on screen and
 * untouched.
 */

export type { QuestKind };

export type TitleCardKind = 'location' | 'landmark' | 'enemy' | 'boss' | 'quest';

export type TitleCard = {
  /** Stable id, for `card` and the chips. */
  id: string;
  kind: TitleCardKind;
  /** The title, in title case: the face sets lowercase as small capitals. */
  title: string;
  /** location: the subline; landmark and enemy: the region; boss: the epithet. */
  kicker?: string;
  /** location only: a cool (sacred) or warm (region) glow. */
  tone?: 'cool' | 'warm';
  /** quest only: its kind, for the icon. */
  quest?: QuestKind;
  /** quest only: the label beside the icon. Defaults to Main Quest, Side Quest or Trial. */
  questLabel?: string;
  /** The chip's name. Defaults to the title. */
  label?: string;
};

export const DEFAULT_CARDS: TitleCard[] = [
  {
    id: 'sanctum',
    kind: 'location',
    tone: 'cool',
    title: 'Tidewell Sanctum',
    kicker: 'Keeper Ilsa Morrow',
    label: 'Sanctum',
  },
  { id: 'region', kind: 'location', tone: 'warm', title: 'Emberfall Vale', label: 'Region' },
  {
    id: 'landmark',
    kind: 'landmark',
    title: 'Ashen Watchtower',
    kicker: 'Westreach Highlands',
    label: 'Landmark',
  },
  {
    id: 'enemy',
    kind: 'enemy',
    title: 'Mire Stalker',
    kicker: 'Brackwood Marsh',
    label: 'Enemy',
  },
  {
    id: 'boss',
    kind: 'boss',
    title: 'Tidebound Colossus',
    kicker: 'Warden of the Sunken Bell',
    label: 'Boss',
  },
  {
    id: 'quest',
    kind: 'quest',
    quest: 'main',
    title: 'The Drowned Archive',
    label: 'Quest',
  },
];

const QUEST_LABEL: Record<QuestKind, string> = {
  main: 'Main Quest',
  side: 'Side Quest',
  trial: 'Trial',
};

/** How long a leaving card takes to fade (the next one waits for it). */
const LEAVE = 260;

/** The demo: a short run of play, as steps at ms from its start. */
const DEMO: ({ at: number } & ({ card: string } | { complete: boolean }))[] = [
  { at: 0, complete: false },
  { at: 0, card: 'sanctum' },
  { at: 3600, card: 'region' },
  { at: 6600, card: 'landmark' },
  { at: 9600, card: 'enemy' },
  { at: 12400, card: 'boss' },
  { at: 16600, card: 'quest' },
  { at: 18900, complete: true },
];
const DEMO_LENGTH = 22400;

/** Cinzel, from Google Fonts, added to the document once. */
const FONT_HREF = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@600..900&display=swap';
function useTitleFont() {
  useEffect(() => {
    if (document.querySelector('link[data-title-cards-font]')) return;
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = FONT_HREF;
    link.dataset.titleCardsFont = '';
    document.head.append(link);
  }, []);
}

type Layer = {
  key: number;
  card: TitleCard;
  phase: 'in' | 'out';
  /** It waits for a leaving card before it plays. */
  late: boolean;
  /** Its stamp arrived with it (so it drops after the band), and, when leaving, whether it had one. */
  stampWithEntry: boolean;
  complete?: boolean;
};

const say = (card: TitleCard | undefined, complete: boolean) => {
  if (!card) return '';
  if (card.kind === 'quest') {
    const label = card.questLabel ?? QUEST_LABEL[card.quest ?? 'main'];
    return complete ? `${label} complete: ${card.title}.` : `${label}: ${card.title}.`;
  }
  return card.kicker ? `${card.title}. ${card.kicker}.` : `${card.title}.`;
};

export type TitleCardsProps = {
  className?: string;
  /** The cards to show. */
  cards?: TitleCard[];
  /** The card shown (controlled). */
  card?: string;
  /** The card it starts with, uncontrolled. */
  defaultCard?: string;
  /** Asked when a chip is picked. */
  onCardChange?: (card: string) => void;
  /** A quest card is completed: its stamp shows (controlled). */
  completed?: boolean;
  /** Asked when the Complete chip is toggled. */
  onCompletedChange?: (completed: boolean) => void;
  /** The card alone, without its plate and chips, to lay over a game. */
  bare?: boolean;
  /** Plays a short run of play while on screen and untouched (uncontrolled only). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

export function TitleCards({
  className,
  cards = DEFAULT_CARDS,
  card: cardProp,
  defaultCard = 'sanctum',
  onCardChange,
  completed: completedProp,
  onCompletedChange,
  bare = false,
  autoplay = true,
  colorScheme,
}: TitleCardsProps) {
  useTitleFont();
  const controlled = cardProp !== undefined;
  const [ownCard, setOwnCard] = useState(defaultCard);
  const id = controlled ? cardProp : ownCard;
  const [ownCompleted, setOwnCompleted] = useState(false);
  const completed = completedProp ?? ownCompleted;
  const current = cards.find((item) => item.id === id) ?? cards[0];

  const { ref, take, playing } = useAutoplay<HTMLDivElement>();
  const demo = playing && autoplay && !controlled && completedProp === undefined;

  const [replays, setReplays] = useState(0);
  const counter = useRef(0);
  const [layers, setLayers] = useState<Layer[]>(() =>
    current ? [{ key: 0, card: current, phase: 'in', late: false, stampWithEntry: completed }] : [],
  );
  const [said, setSaid] = useState('');

  // A new card or a replay: the shown layer leaves, a new one comes in behind it.
  const showKey = `${current?.id}|${replays}`;
  const lastKey = useRef(showKey);
  const completedRef = useRef(completed);
  completedRef.current = completed;
  useLayoutEffect(() => {
    if (lastKey.current === showKey || !current) return;
    lastKey.current = showKey;
    const key = ++counter.current;
    setLayers((old) => {
      const leaving = old
        .filter((layer) => layer.phase === 'in')
        .map((layer): Layer => ({ ...layer, phase: 'out', complete: completedRef.current }));
      const gone = old.filter((layer) => layer.phase === 'out');
      return [
        ...gone,
        ...leaving,
        {
          key,
          card: current,
          phase: 'in',
          late: leaving.length > 0,
          stampWithEntry: completedRef.current,
        },
      ];
    });
    setSaid(say(current, completedRef.current));
    // A safety net: leavers drop out on their animation's end, or after this at the latest.
    const timer = window.setTimeout(
      () => setLayers((now) => now.filter((layer) => layer.phase === 'in' || layer.key > key)),
      LEAVE + 200,
    );
    return () => window.clearTimeout(timer);
  }, [showKey]);

  // Completing the shown quest (after it arrived) is news of its own.
  const lastCompleted = useRef(completed);
  useEffect(() => {
    if (lastCompleted.current === completed) return;
    lastCompleted.current = completed;
    if (current?.kind === 'quest' && completed) setSaid(say(current, true));
  }, [completed, current]);

  const dropOut = useCallback((key: number) => {
    setLayers((now) => now.filter((layer) => layer.key !== key));
  }, []);

  const pick = useCallback(
    (next: string) => {
      if (next === id) setReplays((n) => n + 1);
      else if (!controlled) setOwnCard(next);
      onCardChange?.(next);
    },
    [id, controlled, onCardChange],
  );
  const setCompleted = useCallback(
    (next: boolean) => {
      if (completedProp === undefined) setOwnCompleted(next);
      onCompletedChange?.(next);
    },
    [completedProp, onCompletedChange],
  );

  // The demo, on its own clock: it restarts from the top each time it is handed back.
  const step = useEffectEvent((item: (typeof DEMO)[number]) => {
    if ('card' in item) {
      if (item.card === ownCard) setReplays((n) => n + 1);
      else setOwnCard(item.card);
    } else setOwnCompleted(item.complete);
  });
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (start: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), start + item.at));
      ids.push(window.setTimeout(() => run(0), start + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);

  const questShown = current?.kind === 'quest';

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-bare={bare || undefined}
      data-part="root"
      onPointerDown={controlled ? undefined : take}
      onKeyDown={controlled ? undefined : take}
    >
      <div className={styles.plate} data-part="plate">
        {layers.map((layer) => {
          const out = layer.phase === 'out';
          const stamp = out ? !!layer.complete : completed;
          // The shown card follows edits to its data (a new title under the same id).
          const fresh = !out && current && layer.card.id === current.id ? current : layer.card;
          return (
            <Card
              key={layer.key}
              layer={fresh === layer.card ? layer : { ...layer, card: fresh }}
              stamp={layer.card.kind === 'quest' && stamp}
              onLeft={out ? () => dropOut(layer.key) : undefined}
            />
          );
        })}
      </div>
      {!bare && cards.length > 0 && (
        <div className={styles.chips} role="group" aria-label="Title card" data-part="chips">
          {cards.map((item) => (
            <button
              key={item.id}
              type="button"
              className={styles.chip}
              data-part="chip"
              aria-pressed={item.id === current?.id}
              onClick={() => {
                take();
                // A quest picked afresh starts open; picked again, it replays as it is.
                if (item.kind === 'quest' && item.id !== current?.id) setCompleted(false);
                pick(item.id);
              }}
            >
              {item.label ?? item.title}
            </button>
          ))}
          {cards.some((item) => item.kind === 'quest') && (
            <button
              type="button"
              className={styles.chip}
              data-part="complete-chip"
              aria-pressed={questShown && completed}
              onClick={() => {
                take();
                const quest = questShown ? current : cards.find((item) => item.kind === 'quest');
                if (!questShown && quest) {
                  // Complete straight away: the quest comes in with its stamp.
                  setCompleted(true);
                  pick(quest.id);
                } else setCompleted(!completed);
              }}
            >
              Complete
            </button>
          )}
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}

function Ornamented({ text, kind }: { text: string; kind: 'leaf' | 'scroll' }) {
  const Ornament = kind === 'leaf' ? LeafDash : ScrollEnd;
  return (
    <>
      <span className={styles.ornament} data-side="start" data-shape={kind} data-part="ornament">
        <Ornament />
      </span>
      <span className={styles.kickerText}>{text}</span>
      <span className={styles.ornament} data-side="end" data-shape={kind}>
        <Ornament />
      </span>
    </>
  );
}

function Card({ layer, stamp, onLeft }: { layer: Layer; stamp: boolean; onLeft?: () => void }) {
  const { card } = layer;
  const out = layer.phase === 'out';
  const title = (
    <p className={styles.title} data-part="title">
      {card.title}
    </p>
  );
  return (
    <div
      className={styles.card}
      data-kind={card.kind}
      data-tone={card.kind === 'location' ? (card.tone ?? 'cool') : undefined}
      data-phase={layer.phase}
      data-late={layer.late || undefined}
      data-complete={stamp || undefined}
      aria-hidden={out || undefined}
      style={{ '--wait': layer.late ? `${LEAVE}ms` : '0ms' } as CSSProperties}
      onAnimationEnd={(event) => {
        if (onLeft && event.target === event.currentTarget) onLeft();
      }}
      data-part="card"
    >
      {card.kind === 'location' && (
        <>
          {title}
          {card.kicker && (
            <p className={styles.kicker} data-part="subline">
              <Ornamented text={card.kicker} kind="scroll" />
            </p>
          )}
        </>
      )}
      {(card.kind === 'landmark' || card.kind === 'enemy' || card.kind === 'boss') && (
        <>
          {card.kicker && (
            <p className={styles.kicker} data-part="kicker">
              <Ornamented text={card.kicker} kind="leaf" />
            </p>
          )}
          {card.kind === 'enemy' ? (
            <div className={styles.named}>
              {title}
              <span className={styles.rule} aria-hidden="true" data-part="rule" />
            </div>
          ) : (
            title
          )}
        </>
      )}
      {card.kind === 'quest' && (
        <>
          <p className={styles.questKind} data-part="quest-kind">
            <span className={styles.questIcon} data-part="quest-icon">
              <QuestIcon kind={card.quest ?? 'main'} />
            </span>
            <span>{card.questLabel ?? QUEST_LABEL[card.quest ?? 'main']}</span>
          </p>
          <div className={styles.band} data-part="band">
            {title}
            {stamp && (
              <p
                className={styles.stamp}
                data-with-entry={layer.stampWithEntry || undefined}
                data-part="stamp"
              >
                Complete
              </p>
            )}
          </div>
        </>
      )}
    </div>
  );
}
```

### title-cards/ornaments.tsx

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

/*
 * The title cards' art: the small ornaments either side of a kicker or a subline, and the quest
 * kinds' icons. All are flat SVG in currentColor (or their own fixed colours, for the icons), drawn
 * on small grids and sized in em by the stylesheet. All are decorative: aria-hidden.
 */

/** A quill-like leaf, pointing inwards at the text: a tapered blade with a hooked tail. */
export function LeafDash() {
  return (
    <svg viewBox="0 0 22 8" aria-hidden="true" focusable="false">
      <path
        d="M1 5.1C4.4 2.6 9.8 1.4 21 3.6 13.6 3.8 9.2 4.6 5.6 6.2 4.4 6.7 3.4 6.4 3.6 5.6 3.8 5 4.6 4.8 5.2 5.1Z"
        fill="currentColor"
      />
    </svg>
  );
}

/** A scroll end: a ring and a bead on the outside, then a stem tapering towards the text. */
export function ScrollEnd() {
  return (
    <svg viewBox="0 0 34 12" aria-hidden="true" focusable="false">
      <circle cx="5" cy="6" r="3.1" fill="none" stroke="currentColor" strokeWidth="1.8" />
      <circle cx="11.6" cy="6" r="1.9" fill="currentColor" />
      <path d="M13.2 5.1 33 5.7v.6L13.2 6.9Z" fill="currentColor" />
      <path
        d="M16.4 6.2c1.2 2.2 3.4 3 5.2 2.2"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinecap="round"
      />
    </svg>
  );
}

export type QuestKind = 'main' | 'side' | 'trial';

/** The quest kinds' icons: a winged gold crest, a green speech bubble, a blue diamond. */
export function QuestIcon({ kind }: { kind: QuestKind }) {
  if (kind === 'side')
    return (
      <svg viewBox="0 0 32 24" aria-hidden="true" focusable="false">
        <path
          d="M8 3h16a6.5 6.5 0 0 1 0 13h-8.6L9.6 21.2l.9-5.2H8A6.5 6.5 0 0 1 8 3Z"
          fill="#a9dc14"
          stroke="#5f7d06"
          strokeWidth="1.2"
          strokeLinejoin="round"
        />
        <circle cx="10.4" cy="9.5" r="1.7" fill="#fff" />
        <circle cx="16" cy="9.5" r="1.7" fill="#fff" />
        <circle cx="21.6" cy="9.5" r="1.7" fill="#fff" />
      </svg>
    );
  if (kind === 'trial')
    return (
      <svg viewBox="0 0 28 28" aria-hidden="true" focusable="false">
        <rect
          x="5.2"
          y="5.2"
          width="17.6"
          height="17.6"
          rx="3"
          transform="rotate(45 14 14)"
          fill="#4ea6e0"
          stroke="#1d5f8f"
          strokeWidth="1.2"
        />
        <path d="M14 8.4 20 16.2h-3.1v3.6h-5.8v-3.6H8Z" fill="#fff" />
      </svg>
    );
  // The main quest's crest: a gem in a ring, held up by two swept wings of three feathers.
  return (
    <svg viewBox="0 0 36 22" aria-hidden="true" focusable="false">
      <g fill="#fcc413" stroke="#8a6200" strokeWidth="0.9" strokeLinejoin="round">
        <path d="M14.2 9.6C9.4 6.6 5 6.2 1 7.4c2.6.8 4.6 2 6 3.4-1.8.2-3.4.8-4.8 1.8 2.8.2 5 1 6.6 2.4-1.2.6-2.2 1.4-2.8 2.4 3.6-.4 6.4-1.6 8.6-3.6Z" />
        <path d="M21.8 9.6c4.8-3 9.2-3.4 13.2-2.2-2.6.8-4.6 2-6 3.4 1.8.2 3.4.8 4.8 1.8-2.8.2-5 1-6.6 2.4 1.2.6 2.2 1.4 2.8 2.4-3.6-.4-6.4-1.6-8.6-3.6Z" />
        <circle cx="18" cy="11" r="6" />
        <path d="M18 6.6 21.4 11 18 15.4 14.6 11Z" fill="#fff3bf" />
        <path d="M15.6 18.4h4.8L18 21.4Z" />
      </g>
    </svg>
  );
}
```

### title-cards/title-cards.module.css

```css
/* Title cards (title-cards.tsx): one card at a time, centred on its own dark plate, with a row of
   chips below. Every size of a card is in em of the plate's font size (the location title's size:
   60 px on a wide stage, down to 26 px on a phone, or --title-size), so a card scales as one.

   A game draws its titles straight over the world; here there is no world, so the card sits on a
   plate of warm dark grey, lit a little at its centre, and its colours (cream, a cool or warm glow,
   a boss's white and red, the quest's pale band) stay the same on a light page or a dark one. Only
   the plate's edge, its shadow and the chips follow the colour scheme, through light-dark() tokens.
   Glows are text- and box-shadows, layers behind the sharp letters; no filter touches them.

   Motion: each part of a card plays keyframes as it mounts, offset by --wait when a card is still
   leaving. Titles gather (letter-spacing and text-indent shrink together, so the line stays
   centred) as they fade in, then their glow swells. */

.root {
  /* The titles. */
  --cream: #e9e1d1;
  --white: #f4fafa;
  --cool-edge: #5b7989;
  --cool-glow: rgb(150 205 238 / 0.95);
  --cool-glow-wide: rgb(120 190 235 / 0.5);
  --warm-glow: rgb(255 244 214 / 0.62);
  --warm-glow-wide: rgb(255 236 200 / 0.32);
  --boss-edge: #760808;
  --boss-glow: rgb(190 16 16 / 0.95);
  --boss-glow-wide: rgb(200 20 20 / 0.55);
  --threat: #f2503a;
  --band: #a9a78a;
  --band-done: #a19f82;
  --band-glow: rgb(196 194 156 / 0.55);
  --band-ink: #22211c;
  --stamp: #fff5c2;
  --stamp-glow: rgb(255 232 150 / 0.6);
  /* The plate behind the card, its edge and its shadow on the page. */
  --plate-core: #5a5852;
  --plate: #3a3935;
  --plate-edge: rgb(255 255 255 / 0.06);
  --shade: rgb(20 18 12 / 0.24);
  /* The chips. */
  --chip: #f2f1ee;
  --chip-hover: #e8e7e3;
  --chip-text: #4a4944;
  --chip-edge: rgb(0 0 0 / 0.08);
  --chip-on: #1f1e1b;
  --chip-on-text: #f4f3ef;
  --focus: #2f6fdf;
  --serif: Cinzel, 'Cinzel Fallback', 'Trajan Pro', 'Cormorant SC', Georgia, serif;
  --sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 24px;
  width: 100%;
  min-height: 360px;
  padding: 28px 16px;
  box-sizing: border-box;
  font-family: var(--sans);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate-edge: light-dark(rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.12));
    --shade: light-dark(rgb(20 18 12 / 0.24), rgb(0 0 0 / 0.5));
    --chip: light-dark(#f2f1ee, #26262a);
    --chip-hover: light-dark(#e8e7e3, #303035);
    --chip-text: light-dark(#4a4944, #c9c8c3);
    --chip-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --chip-on: light-dark(#1f1e1b, #ecebe6);
    --chip-on-text: light-dark(#f4f3ef, #1c1b18);
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
/* 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;
}
.root[data-bare] {
  display: block;
  min-height: 0;
  padding: 0;
}

/* The plate: a fixed room, so cards of any kind come and go without moving the chips. */
.plate {
  position: relative;
  display: grid;
  width: min(100%, 13em);
  height: 3.8em;
  border-radius: 0.2em;
  background: radial-gradient(ellipse 70% 80% at 50% 50%, var(--plate-core), var(--plate));
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    0 0.1em 0.5em var(--shade);
  overflow: clip;
  font-size: var(--title-size, clamp(26px, 7.4cqi, 60px));
  line-height: 1;
}
.root[data-bare] .plate {
  width: 100%;
  height: auto;
  min-height: 2.4em;
  border-radius: 0;
  background: none;
  box-shadow: none;
  overflow: visible;
  font-size: var(--title-size, 60px);
}

/* A card: its parts stacked in the plate's middle. Old and new share the one grid cell. */
.card {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-width: 0;
  padding-inline: 0.18em;
  text-align: center;
}
.card[data-phase='out'] {
  pointer-events: none;
  animation: leave 260ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes leave {
  to {
    opacity: 0;
    scale: 0.985;
  }
}

/* The title: Cinzel's capitals and small capitals, never wrapped. */
.title {
  margin: 0;
  color: var(--cream);
  font-family: var(--serif);
  font-weight: 700;
  font-size: 0.8em;
  line-height: 1.12;
  letter-spacing: 0.02em;
  text-indent: 0.02em;
  white-space: nowrap;
  animation: gather 1000ms cubic-bezier(0.16, 0.84, 0.3, 1) calc(var(--wait) + 150ms) both;
}
@keyframes gather {
  from {
    opacity: 0;
    letter-spacing: 0.42em;
    text-indent: 0.42em;
  }
  55% {
    opacity: 1;
  }
}

/* The kicker over a title, or the subline under a location: small sans between ornaments. */
.kicker {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.42em;
  margin: 0 0 0.12em;
  color: var(--cream);
  font-size: max(11px, 0.22em);
  font-weight: 400;
  line-height: 1.3;
  white-space: nowrap;
  animation: fade 500ms ease-out var(--wait) both;
}
.kickerText {
  letter-spacing: 0.01em;
}
.ornament {
  display: grid;
  flex: none;
  animation: spread 620ms cubic-bezier(0.2, 0.8, 0.3, 1) calc(var(--wait) + 80ms) both;
}
.ornament[data-shape='leaf'] svg {
  width: 0.95em;
  height: 0.36em;
}
.ornament[data-shape='scroll'] svg {
  width: 1.5em;
  height: 0.53em;
}
.ornament[data-side='end'] svg {
  scale: -1 1;
}
.ornament[data-side='start'] {
  --from: 0.8em;
}
.ornament[data-side='end'] {
  --from: -0.8em;
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
@keyframes spread {
  from {
    opacity: 0;
    translate: var(--from) 0;
  }
}

/* Location: the largest title, white or cream, its glow swelling once the letters settle. */
.card[data-kind='location'] .title {
  font-size: 1em;
  animation:
    gather 1200ms cubic-bezier(0.16, 0.84, 0.3, 1) var(--wait) both,
    var(--glow-name) 900ms ease-out calc(var(--wait) + 650ms) both;
}
.card[data-kind='location'] .kicker {
  margin: 0.14em 0 0;
  font-size: max(12px, 0.29em);
  font-style: italic;
  font-weight: 500;
  animation: rise 600ms cubic-bezier(0.2, 0.8, 0.3, 1) calc(var(--wait) + 650ms) both;
}
.card[data-kind='location'] .ornament {
  animation-delay: calc(var(--wait) + 750ms);
}
.card[data-tone='cool'] {
  --glow-name: glow-cool;
}
.card[data-tone='cool'] .title {
  color: var(--white);
  -webkit-text-stroke: 0.035em var(--cool-edge);
  paint-order: stroke fill;
  text-shadow:
    0 0 0.07em var(--cool-glow),
    0 0 0.22em var(--cool-glow-wide);
}
.card[data-tone='cool'] .kicker {
  color: var(--white);
  text-shadow:
    0 0 0.16em var(--cool-glow),
    0 0 0.5em var(--cool-glow-wide);
}
.card[data-tone='cool'] .ornament svg {
  color: var(--white);
  overflow: visible;
  /* The glow sits behind the shape, which stays sharp. */
  filter: drop-shadow(0 0 0.08em var(--cool-glow));
}
.card[data-tone='warm'] {
  --glow-name: glow-warm;
}
.card[data-tone='warm'] .title {
  font-size: 0.72em;
  text-shadow:
    0 0 0.1em var(--warm-glow),
    0 0 0.3em var(--warm-glow-wide);
}
@keyframes glow-cool {
  from {
    text-shadow:
      0 0 0 transparent,
      0 0 0 transparent;
  }
  45% {
    text-shadow:
      0 0 0.12em var(--cool-glow),
      0 0 0.42em var(--cool-glow-wide);
  }
}
@keyframes glow-warm {
  from {
    text-shadow:
      0 0 0 transparent,
      0 0 0 transparent;
  }
  45% {
    text-shadow:
      0 0 0.16em var(--warm-glow),
      0 0 0.5em var(--warm-glow-wide);
  }
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 0.35em;
  }
}

/* Landmark: plain cream under its region. */
.card[data-kind='landmark'] .title {
  font-size: 0.8em;
}

/* Enemy: smaller, with a red rule drawn from the centre once the name lands. */
.card[data-kind='enemy'] .title {
  font-size: 0.56em;
  animation-duration: 800ms;
  animation-delay: calc(var(--wait) + 100ms);
}
/* The rule reaches 0.75 em (of the plate) past the name on both sides. */
.named {
  display: grid;
  max-width: 100%;
}
.rule {
  display: block;
  height: 0.065em;
  margin: 0.03em -0.75em 0;
  background: var(--threat);
  transform-origin: 50% 50%;
  animation: draw 520ms cubic-bezier(0.3, 0.8, 0.3, 1) calc(var(--wait) + 620ms) both;
}
@keyframes draw {
  from {
    scale: 0 1;
  }
}

/* Boss: the epithet first, then the name slams in, white with a dark red edge, its glow flares
   and the card shakes once. */
.card[data-kind='boss'] {
  animation: shake 320ms linear calc(var(--wait) + 760ms);
}
.card[data-kind='boss'][data-phase='out'] {
  animation: leave 260ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.card[data-kind='boss'] .kicker {
  color: #fff;
  font-weight: 500;
}
.card[data-kind='boss'] .title {
  font-size: 0.78em;
  font-weight: 800;
  color: #fff;
  -webkit-text-stroke: 0.042em var(--boss-edge);
  paint-order: stroke fill;
  text-shadow:
    0 0 0.1em var(--boss-glow),
    0 0 0.3em var(--boss-glow-wide);
  animation:
    slam 460ms cubic-bezier(0.3, 1.25, 0.5, 1) calc(var(--wait) + 450ms) both,
    flare 1100ms ease-out calc(var(--wait) + 760ms) both;
}
@keyframes slam {
  from {
    opacity: 0;
    scale: 1.32;
    letter-spacing: 0.22em;
    text-indent: 0.22em;
  }
  40% {
    opacity: 1;
  }
}
@keyframes flare {
  from {
    text-shadow:
      0 0 0.12em #ff5a3a,
      0 0 0.6em rgb(230 30 20 / 0.9);
  }
}
@keyframes shake {
  20% {
    translate: -0.04em 0.02em;
  }
  40% {
    translate: 0.035em -0.02em;
  }
  60% {
    translate: -0.025em 0.01em;
  }
  80% {
    translate: 0.012em 0;
  }
}

/* Quest: its kind's icon and label, then its title on a pale band that opens from the centre. */
.questKind {
  display: flex;
  align-items: center;
  gap: 0.3em;
  margin: 0 0 0.2em;
  color: #f4f2ea;
  font-size: max(11px, 0.22em);
  font-style: italic;
  font-weight: 500;
  white-space: nowrap;
  animation: fade 400ms ease-out calc(var(--wait) + 100ms) both;
}
.questIcon {
  display: grid;
  animation: pop 380ms cubic-bezier(0.3, 1.6, 0.5, 1) var(--wait) both;
}
.questIcon svg {
  width: 1.85em;
  height: 1.13em;
  overflow: visible;
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.4;
  }
}
.band {
  position: relative;
  display: grid;
  place-items: center;
  align-self: stretch;
  height: 0.72em;
  background: var(--band);
  box-shadow:
    0 0 0.1em var(--band-glow),
    0 0 0.24em var(--band-glow);
  transition: background-color 400ms ease-out;
  animation: open 460ms cubic-bezier(0.25, 0.8, 0.3, 1) calc(var(--wait) + 250ms) both;
}
@keyframes open {
  from {
    scale: 0 1;
    opacity: 0.4;
  }
}
.card[data-kind='quest'] .title {
  color: var(--band-ink);
  font-size: 0.69em;
  font-weight: 800;
  animation-delay: calc(var(--wait) + 450ms);
  animation-duration: 900ms;
}
.card[data-complete] .band {
  background: var(--band-done);
}
/* COMPLETE: drops from large onto the band's lower right, with a flash of glow. */
.stamp {
  position: absolute;
  left: 63%;
  top: 0.62em;
  margin: 0;
  color: var(--stamp);
  font-family: var(--serif);
  font-weight: 800;
  font-size: 0.57em;
  line-height: 1;
  white-space: nowrap;
  text-shadow:
    0 0 0.06em rgb(48 42 20 / 0.55),
    0 0 0.3em var(--stamp-glow);
  transform-origin: 40% 55%;
  animation:
    stamp 440ms cubic-bezier(0.5, 0, 0.6, 1) both,
    stamp-glow 900ms ease-out 300ms both;
}
.stamp[data-with-entry] {
  animation-delay: calc(var(--wait) + 1200ms), calc(var(--wait) + 1500ms);
}
@keyframes stamp {
  from {
    opacity: 0;
    scale: 2.6;
  }
  60% {
    opacity: 1;
    scale: 0.92;
  }
  80% {
    scale: 1.03;
  }
}
@keyframes stamp-glow {
  from {
    text-shadow:
      0 0 0.06em rgb(48 42 20 / 0.55),
      0 0 0.7em rgb(255 240 170 / 0.95);
  }
}

/* The chips: pick a card (the shown one replays), and complete the quest. */
.chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  max-width: 600px;
}
.chip {
  min-height: 30px;
  padding: 0 12px;
  border: 1px solid var(--chip-edge);
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-text);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 150ms,
    color 150ms,
    scale 150ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.chip:hover {
  background: var(--chip-hover);
}
.chip[aria-pressed='true'] {
  border-color: transparent;
  background: var(--chip-on);
  color: var(--chip-on-text);
}
.chip:active {
  scale: 0.95;
}
.chip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
@container (max-width: 479px) {
  .root:not([data-bare]) {
    gap: 18px;
  }
  .plate {
    height: 4.8em;
  }
  .chips {
    gap: 5px;
  }
  .chip {
    min-height: 28px;
    padding: 0 10px;
    font-size: 12px;
  }
}

.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Reduced motion: every part only fades, briefly; no gathering, slams, shakes, pops or drops. */
@media (prefers-reduced-motion: reduce) {
  .card .title,
  .card .kicker,
  .card .ornament,
  .card .rule,
  .card .questKind,
  .card .questIcon,
  .card .band,
  .card .stamp,
  .card[data-kind='location'] .title,
  .card[data-kind='location'] .kicker,
  .card[data-kind='boss'] .title {
    animation: fade 200ms ease-out var(--wait) both;
  }
  .card[data-kind='boss'] {
    animation: none;
  }
  .card[data-phase='out'],
  .card[data-kind='boss'][data-phase='out'] {
    animation: fade-out 160ms ease-in forwards;
  }
  .band,
  .chip {
    transition: none;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
```

### title-cards/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.
 */
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 };
}
```