# Game over screen

> A game’s game over screen: a glowing red serif title that fades in slowly, then Continue and Quit under a selection frame, and a hint line.

What a game shows after the player falls. The screen goes black and quiet; the title fades in while its red glow builds, and only once it has landed do Continue and Quit rise in, the selected one wearing a light frame with a soft glow and a small triangle in each inside corner. A hint at the bottom says what the selected option does, and a small star in the corner breathes so the screen reads as waiting. Move with the arrows or W and S, choose with Enter or A: the frame flashes and the screen fades back to black.

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

Category: Games · Screens.

## Anatomy

1. **Screen**: Its own black surface, a 1012 × 569 design in `--u` units, with a faint edge on a dark page.
2. **Title**: The only colour on the screen: a red serif in small capitals, condensed to 0.755 of its width and glowing. The real `h2` names the section.
3. **Options**: A group of real buttons, top to bottom, 13 units apart, with one roving tab stop.
4. **Selected option**: A dark box that loses its faint edge and lifts to `#121210` while selected.
5. **Selection frame**: One light frame with a soft glow and a triangle in each inside corner; it jumps to the selected option and lands with a short settle.
6. **Option**: An unselected option: black, a 1-unit `#252421` edge, its label in light italics.
7. **Hint**: What the selected option does, in a polite live region that also describes the selected button.
8. **Star**: A small four-pointed star in the corner that breathes, so the screen reads as waiting, not frozen. Decorative; `glyph={false}` hides it.

## When to use

- The moment the player falls and the run is over: the world is gone and the game needs one decision before anything else.
- Two or three ways back in, such as Continue from the last save, Retry from a checkpoint or Quit to the title, each with a line saying what it does.
- A full-screen beat that should feel final but short: a slow arrival, then a choice the player can make with a pad, the keys or a click.
- Screens where a button still being mashed from the fight must not skip the choice: input counts only once the options are in.

## When not to use

- A pause during play, where the world should stay visible behind the menu. Use a system menu or a pause overlay.
- A win, a level clear or a score summary with stats to read. Use a results screen.
- A question about one action, such as “Load this save data?”. Use a game modal with two choices.
- Losing a life that the player keeps playing through. Use the HUD (a hearts row or a heart meter) and a short toast.
- An error or a confirmation in an app outside a game. Use a dialog or an alert.

## Installation

Install it with the shadcn CLI, into components/game-over-screen of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/game-over-screen.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 { GameOverScreen } from './game-over-screen';

export function Defeat({ dead, onRetry, onQuit }: { dead: boolean; onRetry: () => void; onQuit: () => void }) {
  return (
    <GameOverScreen
      open={dead}
      options={[
        { label: 'Continue', hint: 'Continue from last save.' },
        { label: 'Quit', hint: 'Return to the title screen.' },
      ]}
      onChoose={(index) => (index === 0 ? onRetry() : onQuit())}
    />
  );
}
```

## States

- **Ready**: The options are in and Continue is selected: choices count from here.
- **Quit selected**: The frame on Quit and the hint changed to “Return to the title screen.”
- **Chosen**: The frame flashes once and the button dips to 0.97; then the screen fades to black and `onChoose` has fired.
- **Dark**: The beat of black before the arrival, and where a choice or `open={false}` leaves it.
- **Three options**: Retry, Load save and Quit: the group grows down and the frame steps 50 units per option.
- **Custom title**: `title="You Fell"`: every word starts with a full-height capital, the rest at 0.75 em.
- **No star**: `glyph={false}`: only the title, the options and the hint.
- **Dark page**: `colorScheme="dark"`: the screen keeps its black; a faint light edge keeps it apart from the page.

## How it works

- The screen is designed at 1012 × 569 units. One design unit, --u, follows the component’s width through a container query unit (100cqi / 1012, kept between 0.82 px and 1.25 px, or --gos-unit when set). Below about 830 px wide the unit stops shrinking and the screen grows taller than 16:9 instead, so the type stays readable; everything is placed from the centre or the bottom edge, so the layout holds at any height.
- The title is real text in a system serif, bold: the first letter of each word at full size and the rest at 0.75 em, so it reads as small capitals, condensed to 0.755 of its width (a scale on the text, so it stays vector-sharp) to match narrow display capitals. Its glow is three red text shadows (4, 12 and 22 units) around #ff2800.
- The options are buttons 202 × 37 units, 50 units apart: black with a faint edge, or a dark fill when selected. The selection frame is one element over them, a 1.2-unit light line with an outer glow, a warm inner glow and four corner triangles drawn as gradients. Each move remounts it on the new option, so it lands slightly large and tightens in 260 ms, as a game cursor snaps; it then breathes, its glow swelling every 2.4 s.
- The arrival is keyframes that run while data-phase is arrive or ready, so they start once and do not restart when choices open: a 350 ms beat of black, the title fading in from 1.06 × over 1.8 s while its glow overshoots and settles, the options rising 8 units at 1.9 s and 2.04 s, the frame at 2.15 s, the hint at 2.45 s and the star at 2.7 s. Choices count from 2.6 s, so a button mashed from the fight does not skip the screen.
- Choosing flashes the frame white and dips the button to 0.97 for 420 ms, then the whole screen fades out over 900 ms and reports the choice. Uncontrolled, a beat of black follows and the arrival plays again; with open, the screen waits until open goes false and back to true.
- Uncontrolled, a demo plays while the screen is on screen and untouched: the arrival, the selection down to Quit (the hint changes to its line) and back, Continue chosen, the fade, and again. Any key, tap or click hands it to the reader.
- It is a whole screen, so it draws its own black surface with no picture behind it, and keeps its colours on a light page and a dark one; on a dark page a faint light edge (a light-dark() token) keeps it apart from the page.

## API reference

- **title** (`string`, default `'Game Over'`): The title, set in the glowing red serif small capitals.
- **options** (`GameOverOption[]`, default `Continue, Quit`): The options, top to bottom, each { label, hint? }: the hint is the line shown at the bottom while that option is selected.
- **selected** (`number`): The selected option’s index, controlled. Turns the demo off.
- **defaultSelected** (`number`, default `0`): Uncontrolled, the option selected each time the screen arrives.
- **onSelectedChange** (`(index: number) => void`): Called when the selection moves, from keys, hover or focus.
- **onChoose** (`(index: number, option: GameOverOption) => void`): Called when an option is chosen (click, Enter, Space or A), as the frame flashes and the screen starts to fade out.
- **open** (`boolean`): Controlled arrival: true plays it, false fades the screen to black; after a choice the screen stays dark until open goes false and true again. Without it, the arrival plays when the screen comes into view and again after each choice. Turns the demo off.
- **glyph** (`boolean`, default `true`): Show the breathing star in the bottom-left corner.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a loop of the arrival, a move down and back, and a choice. Any input stops it.
- **still** (`'ready' | 'chosen' | 'dark'`): Holds the screen in one moment with no arrival, demo or fade: ready (the options in), chosen (the selected option just chosen) or dark (the black before arrival). For stills and documentation; turns the demo off.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The screen keeps its colours on both; the edge and the page focus colour follow it. Set --gos-unit on the root to fix the size (1px is the 1012 × 569 design).
- **className** (`string`): Added to the root.

## Accessibility

- The screen is a section named by its title, a real h2 (“Game Over”); the condensed small-capital drawing of it is hidden from assistive technology so the name reads as words.
- The options are real buttons in a group, with a roving tab stop: Tab reaches the selected one, the arrows or W and S move the selection and focus (wrapping), and Enter, Space or A choose. A mouse selects by hovering.
- The selected button carries aria-current and is described by the hint line, which is a polite live region, so moving the selection reads what the option does (“Return to the title screen.”).
- Until the options have arrived the buttons are aria-disabled and choices are ignored. After a choice, when the screen arrives again, focus returns to the first option if it was inside the screen.
- Focus shows as a light outline around the focused button, outside the selection frame. The corner star is decorative and hidden.
- Under reduced motion the demo does not play and nothing animates: the screen appears whole, moves are instant, and a choice cuts to black and back.

## Source

### game-over-screen/game-over-screen.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './game-over-screen.module.css';

/*
 * Game over screen: what a game shows after the player falls. The world is gone, the screen is
 * black and quiet, and it has one job: say it is over, then get the player back in quickly.
 *
 * - The title is the only colour on the screen: a red serif in small capitals (the first letter of
 *   each word full height, the rest at 0.75 em), condensed to 0.755 of its width the way display
 *   capitals are (still vector text), glowing with layered red text shadows.
 * - Two options under it, Continue and Quit, each a dark box with a faint edge. The selected one
 *   wears the selection frame: a light 1-unit line with a soft glow and a small triangle filling
 *   each inside corner. The frame is one element that jumps to the selected option and lands
 *   with a short settle (slightly large, then tight), as a game cursor snaps.
 * - A hint line at the bottom says what the selected option does ("Continue from last save."), and
 *   a small star in the corner breathes, so the screen reads as waiting, not frozen.
 * - Arrival is slow on purpose: a beat of black, the title fades in while its glow builds and it
 *   settles from a little larger, and only then do the options rise in one after the other, then
 *   the hint and the star. Choices are ignored until the options are in, so a button still being
 *   mashed from the fight does not skip the screen.
 * - Choosing (a click, Enter, Space or A) flashes the frame and dips the button, then the screen
 *   fades back to black and reports the choice. Uncontrolled, the arrival then plays again; with
 *   `open`, the screen stays dark until `open` goes false and true again.
 * - Keys while focus is inside: the arrows or W and S move the selection (and focus), wrapping;
 *   Enter, Space or A choose. A mouse selects by hovering, as a game cursor would.
 * - Uncontrolled, a demo plays while it is on screen and untouched: the arrival, the selection
 *   going down to Quit and back, Continue chosen, the fade, and again. Any input stops it.
 * - `still` holds it in one moment (ready, chosen or dark) with no arrival, demo or fade, for
 *   stills and documentation.
 * - It is a whole screen, so it draws its own black surface (a 1012 × 569 design in --u units;
 *   --gos-unit fixes the unit), which reads on a light page and, with a faint edge, on a dark
 *   one. Below about 830 px wide the unit stops shrinking and the screen grows taller than 16:9
 *   instead, so text stays readable.
 */

export type GameOverOption = {
  /** The button's label. */
  label: string;
  /** The hint line shown while this option is selected. */
  hint?: string;
};

const DEFAULT_OPTIONS: GameOverOption[] = [
  { label: 'Continue', hint: 'Continue from last save.' },
  { label: 'Quit', hint: 'Return to the title screen.' },
];

type Phase = 'dark' | 'arrive' | 'ready' | 'chosen' | 'leave';

/** Arrival: when choices start to count, in ms from its start (the options are in by then). */
const ARRIVE = 2600;
/** The frame's flash before the fade, the fade to black, and the pause before coming back. */
const FLASH = 420;
const LEAVE = 900;
const PAUSE = 1100;
/** The demo, in ms from the moment the screen is ready: selections, then a choice. */
const DEMO: { at: number; select?: number; choose?: boolean }[] = [
  { at: 1500, select: 1 },
  { at: 3000, select: 0 },
  { at: 4300, choose: true },
];

export function GameOverScreen({
  title = 'Game Over',
  options = DEFAULT_OPTIONS,
  selected,
  defaultSelected = 0,
  onSelectedChange,
  onChoose,
  open,
  glyph = true,
  autoplay = true,
  still,
  colorScheme,
  className = '',
}: {
  /** The title, in the red serif. */
  title?: string;
  /** The options, top to bottom, each with the hint shown while it is selected. */
  options?: GameOverOption[];
  /** The selected option's index, controlled. */
  selected?: number;
  /** Uncontrolled, the option selected when the screen arrives. */
  defaultSelected?: number;
  /** Called when the selection moves (keys, hover, focus). */
  onSelectedChange?: (index: number) => void;
  /** Called when an option is chosen, as the screen starts to fade out. */
  onChoose?: (index: number, option: GameOverOption) => void;
  /** Controlled arrival: true plays it, false fades the screen to black. Uncontrolled, it plays
   *  when the screen comes on screen and again after each choice. */
  open?: boolean;
  /** Show the breathing star in the bottom-left corner. */
  glyph?: boolean;
  /** While on screen and untouched, a loop of the arrival, a move and a choice. */
  autoplay?: boolean;
  /** Holds the screen in one moment, with no arrival, demo or fade: 'ready' (the options in),
   *  'chosen' (the selected option just chosen) or 'dark' (the black before arrival). For stills
   *  and documentation; turns the demo off. */
  still?: 'ready' | 'chosen' | 'dark';
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLElement>(8000);
  const id = useId().replace(/[^a-zA-Z0-9-]/g, '');
  const titleId = `gos-title-${id}`;
  const hintId = `gos-hint-${id}`;
  const count = Math.max(1, options.length);
  const clamp = (index: number) => ((index % count) + count) % count;

  const [ownSelected, setOwnSelected] = useState(defaultSelected);
  const current = clamp(selected ?? ownSelected);
  const [phase, setPhase] = useState<Phase>(still ?? 'dark');
  // A still screen follows `still` and nothing else.
  useEffect(() => {
    if (still) setPhase(still);
  }, [still]);
  /** Counts arrivals (for the demo) and moves (so the frame's landing runs again). */
  const [run, setRun] = useState(0);
  const [moves, setMoves] = useState(0);
  /** After a choice with `open` given: stay dark until `open` goes false. */
  const [held, setHeld] = useState(false);
  const shownOpen = open ?? true;
  const demo =
    autoplay && playing && selected === undefined && open === undefined && still === undefined;
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const timers = useRef<number[]>([]);
  const later = useCallback((ms: number, action: () => void) => {
    timers.current.push(window.setTimeout(action, ms));
  }, []);
  useEffect(() => () => timers.current.forEach((timer) => window.clearTimeout(timer)), []);

  /** Whether the screen has left once (a choice): the next arrival waits a beat of black. */
  const left = useRef(false);

  const select = (index: number) => {
    const next = clamp(index);
    if (next === current) return;
    setOwnSelected(next);
    setMoves((value) => value + 1);
    onSelectedChange?.(next);
  };

  const choose = (index: number) => {
    if (phase !== 'ready' || still) return;
    const at = clamp(index);
    setPhase('chosen');
    left.current = true;
    if (open !== undefined) setHeld(true);
    onChoose?.(at, options[at]);
    later(reduced ? 0 : FLASH, () => setPhase('leave'));
    later(reduced ? 0 : FLASH + LEAVE, () => setPhase('dark'));
  };

  // Arrival: once on screen, while open, from black; after a choice, a pause of black first.
  const arrive = useEffectEvent(() => {
    if (selected === undefined) {
      const first = clamp(defaultSelected);
      setOwnSelected(first);
      // Keyboard focus follows the selection back to the first option.
      if (root.current?.contains(document.activeElement)) buttons.current[first]?.focus();
    }
    setMoves(0);
    setRun((value) => value + 1);
    setPhase(reduced ? 'ready' : 'arrive');
  });
  useEffect(() => {
    if (still || phase !== 'dark' || !shownOpen || held || !visible) return;
    const timer = window.setTimeout(arrive, left.current && !reduced ? PAUSE : 0);
    return () => window.clearTimeout(timer);
  }, [still, phase, shownOpen, held, visible, reduced]);
  useEffect(() => {
    if (still || phase !== 'arrive') return;
    const timer = window.setTimeout(() => setPhase('ready'), ARRIVE);
    return () => window.clearTimeout(timer);
  }, [still, phase, run]);

  // Controlled close: fade to black; the next open plays the arrival again.
  useEffect(() => {
    if (open !== false || still) return;
    setHeld(false);
    setPhase((now) => (now === 'dark' ? now : 'leave'));
    const timer = window.setTimeout(() => setPhase('dark'), reduced ? 0 : LEAVE);
    return () => window.clearTimeout(timer);
  }, [open, still, reduced]);

  // The demo: once ready, a move down, a move back, a choice. Taking over clears what is left.
  const demoStep = useEffectEvent((step: (typeof DEMO)[number]) => {
    if (step.select !== undefined) select(step.select);
    if (step.choose) choose(current);
  });
  useEffect(() => {
    if (!demo || phase !== 'ready') return;
    const pending = DEMO.map((step) => window.setTimeout(() => demoStep(step), step.at));
    return () => pending.forEach((timer) => window.clearTimeout(timer));
  }, [demo, phase, run]);

  const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    const key = event.key.toLowerCase();
    const step = key === 'arrowdown' || key === 's' ? 1 : key === 'arrowup' || key === 'w' ? -1 : 0;
    if (step) {
      event.preventDefault();
      take();
      const next = clamp(current + step);
      select(next);
      if (root.current?.contains(document.activeElement)) buttons.current[next]?.focus();
      return;
    }
    if (key === 'a' && !event.shiftKey) {
      event.preventDefault();
      take();
      choose(current);
    }
  };

  const ready = phase === 'ready';
  const hint = options[current]?.hint ?? '';
  const words = title.split(' ');

  return (
    <section
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-phase={phase}
      data-still={still ? '' : undefined}
      aria-labelledby={titleId}
      onKeyDown={onKeyDown}
      onPointerDown={take}
    >
      <div className={styles.screen} data-part="screen">
        <div className={styles.stage}>
          <h2 className={styles.title} id={titleId} data-part="title" aria-label={title}>
            <span className={styles.glow} data-part="lettering" aria-hidden="true">
              {words.map((word, index) => (
                <span key={index} className={styles.word}>
                  <span>{word.charAt(0).toUpperCase()}</span>
                  <span className={styles.rest}>{word.slice(1).toUpperCase()}</span>
                </span>
              ))}
            </span>
          </h2>

          <div
            className={styles.options}
            data-part="options"
            role="group"
            aria-label="Options"
            style={{ '--count': count, '--at': current } as CSSProperties}
          >
            <span
              key={moves}
              className={styles.frame}
              data-part="frame"
              data-moved={moves > 0 || undefined}
              data-chosen={phase === 'chosen' || undefined}
              aria-hidden="true"
            />
            {options.map((option, index) => (
              <button
                key={option.label + index}
                ref={(element) => {
                  buttons.current[index] = element;
                }}
                type="button"
                className={styles.option}
                data-part="option"
                data-selected={index === current || undefined}
                data-chosen={(phase === 'chosen' && index === current) || undefined}
                style={{ '--i': index } as CSSProperties}
                tabIndex={index === current ? 0 : -1}
                aria-current={index === current || undefined}
                aria-disabled={!ready || undefined}
                aria-describedby={index === current ? hintId : undefined}
                onFocus={() => select(index)}
                onPointerEnter={(event) => {
                  if (event.pointerType === 'mouse' && ready) select(index);
                }}
                onClick={() => {
                  take();
                  select(index);
                  choose(index);
                }}
              >
                {option.label}
              </button>
            ))}
          </div>

          <p className={styles.hint} id={hintId} data-part="hint" aria-live="polite">
            <span key={current} className={styles.hintText} data-part="hint-text">
              {hint}
            </span>
          </p>

          {glyph && (
            <svg className={styles.glyph} data-part="glyph" viewBox="0 0 16 16" aria-hidden="true">
              <path d="M8 0 L10.3 5.7 L16 8 L10.3 10.3 L8 16 L5.7 10.3 L0 8 L5.7 5.7 Z" />
              <circle cx="8" cy="8" r="1.6" className={styles.glyphCore} />
            </svg>
          )}
        </div>
      </div>
    </section>
  );
}
```

### game-over-screen/game-over-screen.module.css

```css
/* Game over screen (game-over-screen.tsx): a whole screen, so it draws its own black surface.

   Every size is a multiple of --u, one design unit: the screen is designed at 1012 × 569 units,
   and --u follows the component's width (a container query unit) between 0.82 px and 1.25 px, or
   --gos-unit when set. Below about 830 px wide the unit stops shrinking and the screen grows
   taller than 16:9 instead; everything is placed from the centre or the bottom, so it holds.

   Colours are the tokens below. The screen keeps its own: black, a red title, light ink. What sits
   on the page itself follows the scheme through light-dark(): a faint edge that keeps the black
   screen apart from a dark page, and the focus outline.

   Motion (all transform, opacity and shadows): arrival keyframes run while data-phase is arrive or
   ready (so they start when it turns arrive, and do not restart when it turns ready); the title
   fades in as its glow builds, then the options rise one by one, then the hint and the star. The
   frame jumps to the selected option and lands with a short settle; a choice flashes it,
   and leaving fades the whole stage out. */

.root {
  --screen: #000;
  --red: #ff2800;
  --red-glow: rgb(255 40 0 / 0.7);
  --red-haze: rgb(255 40 0 / 0.38);
  --ink: #e2ded3;
  --option: #000;
  --option-on: #121210;
  --option-edge: #252421;
  --frame: #d6d1ba;
  --frame-glow: rgb(226 222 211 / 0.42);
  --frame-warm: rgb(196 180 110 / 0.5);
  --edge: rgb(0 0 0 / 0);
  --focus: #1b1a18;
  --u: var(--gos-unit, clamp(0.82px, 100cqi / 1012, 1.25px));
  container-type: inline-size;
  display: block;
  box-sizing: border-box;
  width: 100%;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.13));
    --focus: light-dark(#1b1a18, #fff);
  }
}
/* 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;
}

.screen {
  position: relative;
  overflow: hidden;
  width: 100%;
  height: calc(569 * var(--u));
  border-radius: calc(4 * var(--u));
  background: var(--screen);
  box-shadow: 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  user-select: none;
  -webkit-user-select: none;
}
.stage {
  position: absolute;
  inset: 0;
}
.root[data-phase='dark'] .stage {
  opacity: 0;
}
.root[data-phase='leave'] .stage {
  opacity: 0;
  transition: opacity 900ms cubic-bezier(0.4, 0, 0.6, 1);
}

/* ---- The title: a red serif in small capitals, glowing ---- */

.title {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(50% - 57 * var(--u));
  margin: 0;
  display: flex;
  justify-content: center;
  color: var(--red);
  font-family: Georgia, 'Times New Roman', Times, serif;
  font-size: calc(65.5 * var(--u));
  font-weight: 700;
  font-style: normal;
  line-height: 1;
  letter-spacing: 0;
  white-space: nowrap;
}
.glow {
  display: flex;
  gap: 0.29em;
  /* System serifs are wide; condensed to the kit's narrow display capitals. Still vector text. */
  scale: 0.755 1;
  text-shadow:
    0 0 calc(4 * var(--u)) var(--red-glow),
    0 0 calc(12 * var(--u)) var(--red-glow),
    0 0 calc(22 * var(--u)) var(--red-haze);
}
.word {
  display: inline-flex;
  align-items: baseline;
}
.rest {
  font-size: 0.75em;
  letter-spacing: 0.01em;
}

/* ---- The options ---- */

.options {
  position: absolute;
  left: 50%;
  top: calc(50% + 49.5 * var(--u));
  width: calc(202 * var(--u));
  translate: -50% 0;
}
.option {
  position: relative;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  height: calc(37 * var(--u));
  margin: 0 0 calc(13 * var(--u));
  padding: 0;
  border: max(1px, calc(1 * var(--u))) solid var(--option-edge);
  border-radius: calc(1.5 * var(--u));
  background: var(--option);
  color: var(--ink);
  font: inherit;
  font-size: calc(14.5 * var(--u));
  font-style: italic;
  font-weight: 450;
  letter-spacing: 0.005em;
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
  outline: none;
  transition:
    background-color 160ms ease-out,
    border-color 160ms ease-out,
    scale 120ms ease-out;
}
.option:last-of-type {
  margin-bottom: 0;
}
.option[data-selected] {
  border-color: transparent;
  background: var(--option-on);
}
.option[data-chosen] {
  scale: 0.97;
}
.option[aria-disabled] {
  cursor: default;
}
.option:focus-visible::after {
  content: '';
  position: absolute;
  inset: calc(-7 * var(--u));
  border: 2px solid var(--focus);
  border-radius: calc(4 * var(--u));
  pointer-events: none;
}
/* Inside the black screen the focus ring is always light. */
.screen .option:focus-visible::after {
  border-color: rgb(255 255 255 / 0.85);
}

/* The selection frame: a light line with a soft glow both ways and a small triangle in each inside
   corner. One element, moved to the selected option (remounted per move, so it lands again). */
.frame {
  position: absolute;
  z-index: 1;
  left: calc(-1.5 * var(--u));
  top: calc(-1.5 * var(--u) + var(--at) * 50 * var(--u));
  width: calc(205 * var(--u));
  height: calc(40 * var(--u));
  box-sizing: border-box;
  border: max(1px, calc(1.2 * var(--u))) solid var(--frame);
  border-radius: calc(2 * var(--u));
  background:
    linear-gradient(135deg, var(--frame) 50%, transparent 50%) top left / calc(6 * var(--u))
      calc(6 * var(--u)) no-repeat,
    linear-gradient(225deg, var(--frame) 50%, transparent 50%) top right / calc(6 * var(--u))
      calc(6 * var(--u)) no-repeat,
    linear-gradient(45deg, var(--frame) 50%, transparent 50%) bottom left / calc(6 * var(--u))
      calc(6 * var(--u)) no-repeat,
    linear-gradient(315deg, var(--frame) 50%, transparent 50%) bottom right / calc(6 * var(--u))
      calc(6 * var(--u)) no-repeat;
  box-shadow:
    0 0 calc(3 * var(--u)) calc(0.5 * var(--u)) var(--frame-glow),
    0 0 calc(7 * var(--u)) var(--frame-glow),
    inset 0 0 calc(4 * var(--u)) var(--frame-warm);
  pointer-events: none;
}
.frame[data-moved] {
  animation: land 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes land {
  from {
    scale: 1.045 1.12;
    opacity: 0.6;
  }
}
.root[data-phase='ready'] .frame {
  animation:
    land 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
    breathe 2.4s ease-in-out 260ms infinite;
}
@keyframes breathe {
  50% {
    box-shadow:
      0 0 calc(4 * var(--u)) calc(1 * var(--u)) var(--frame-glow),
      0 0 calc(11 * var(--u)) var(--frame-glow),
      inset 0 0 calc(5 * var(--u)) var(--frame-warm);
  }
}
.frame[data-chosen] {
  animation: flash 420ms ease-out;
}
@keyframes flash {
  20% {
    border-color: #fff;
    box-shadow:
      0 0 calc(4 * var(--u)) calc(2 * var(--u)) rgb(255 255 255 / 0.9),
      0 0 calc(16 * var(--u)) rgb(255 255 255 / 0.6),
      inset 0 0 calc(10 * var(--u)) rgb(255 255 255 / 0.5);
  }
}

/* ---- The hint and the corner star ---- */

.hint {
  position: absolute;
  left: calc(16 * var(--u));
  right: calc(16 * var(--u));
  bottom: calc(51 * var(--u));
  margin: 0;
  text-align: center;
  font-size: calc(14 * var(--u));
  font-style: italic;
  font-weight: 450;
  line-height: 1.2;
}
.hintText {
  display: inline-block;
  animation: swap 240ms ease-out;
}
@keyframes swap {
  from {
    opacity: 0;
    translate: 0 calc(3 * var(--u));
  }
}
.glyph {
  position: absolute;
  left: calc(46 * var(--u));
  bottom: calc(23 * var(--u));
  width: calc(17 * var(--u));
  height: calc(17 * var(--u));
  fill: var(--ink);
  overflow: visible;
}
.glyphCore {
  fill: var(--screen);
}
@keyframes pulse {
  50% {
    opacity: 0.45;
    scale: 0.86;
  }
}

/* ---- Arrival: a beat of black, the title, then the options, the hint, the star ---- */

.root:is([data-phase='arrive'], [data-phase='ready']) .title {
  animation: title-in 1800ms cubic-bezier(0.25, 0.1, 0.25, 1) 350ms backwards;
}
.root:is([data-phase='arrive'], [data-phase='ready']) .glow {
  animation: glow-in 2200ms cubic-bezier(0.4, 0, 0.3, 1) 350ms backwards;
}
@keyframes title-in {
  from {
    opacity: 0;
    scale: 1.06;
  }
  60% {
    opacity: 1;
  }
}
@keyframes glow-in {
  from {
    text-shadow:
      0 0 0 rgb(255 40 0 / 0),
      0 0 0 rgb(255 40 0 / 0),
      0 0 0 rgb(255 40 0 / 0);
  }
  70% {
    text-shadow:
      0 0 calc(5 * var(--u)) rgb(255 70 20 / 0.95),
      0 0 calc(16 * var(--u)) rgb(255 40 0 / 0.85),
      0 0 calc(32 * var(--u)) rgb(255 40 0 / 0.5);
  }
}
.root:is([data-phase='arrive'], [data-phase='ready']) .option {
  animation: rise 520ms cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
  animation-delay: calc(1900ms + var(--i) * 140ms);
}
.root:is([data-phase='arrive'], [data-phase='ready']) .frame:not([data-moved]) {
  animation:
    frame-in 520ms cubic-bezier(0.2, 0.8, 0.2, 1) 2150ms backwards,
    breathe 2.4s ease-in-out 2670ms infinite;
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 calc(8 * var(--u));
  }
}
@keyframes frame-in {
  from {
    opacity: 0;
    scale: 1.06 1.25;
  }
}
.root:is([data-phase='arrive'], [data-phase='ready']) .hint {
  animation: fade 700ms ease-out 2450ms backwards;
}
.root:is([data-phase='arrive'], [data-phase='ready']) .glyph {
  animation:
    fade 700ms ease-out 2700ms backwards,
    pulse 2.6s ease-in-out 3400ms infinite;
}
@keyframes fade {
  from {
    opacity: 0;
  }
}

/* ---- Still (the still prop): held in one moment, with no arrival; the frame keeps breathing ---- */

.root[data-still] :is(.title, .glow, .option, .hint, .hintText, .glyph) {
  animation: none;
}
.root[data-still] .frame:not([data-chosen]) {
  animation: breathe 2.4s ease-in-out infinite;
}
/* Held at the flash's peak, so a still of the chosen moment reads as chosen. */
.root[data-still] .frame[data-chosen] {
  animation: none;
  border-color: #fff;
  box-shadow:
    0 0 calc(4 * var(--u)) calc(2 * var(--u)) rgb(255 255 255 / 0.9),
    0 0 calc(16 * var(--u)) rgb(255 255 255 / 0.6),
    inset 0 0 calc(10 * var(--u)) rgb(255 255 255 / 0.5);
}

@media (prefers-reduced-motion: reduce) {
  .root .title,
  .root .glow,
  .root .option,
  .root .frame,
  .root .hint,
  .root .hintText,
  .root .glyph {
    animation: none !important;
    transition: none !important;
  }
  .root[data-phase='leave'] .stage {
    transition: none;
  }
}
```

### game-over-screen/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 };
}
```