# Skeleton build

> Cards drawn first as dotted blueprints, measured from the cards themselves, then swept in by a scan line one after another.

A loading state that looks like an interface being generated. Each card shows as its own blueprint (dotted runs for its words, dotted boxes and rings for its controls, its outline with corner handles, guides and sizes, over a grid of dots) while a bracket hops from part to part naming what it places. Then a dotted scan line runs down each card in turn and the real card resolves above it, soft at first and then sharp. The three sample cards (a heart-rate monitor, a stacked bar chart of token spend and a music player) are live once shown.

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

Category: Feedback.

## Anatomy

1. **Stage**: The pale ground that holds the cards in a row, and stacks them when it is narrow. Its colours follow the page, or `colorScheme`.
2. **Card**: The real content, rendered from the start in its place, so nothing moves when it appears. While loading it is clipped away.
3. **Blueprint**: The card’s dotted outline with corner handles and guides, measured from the card itself. A bracket hops from part to part, naming each.
4. **Dot grid**: Dots every 15 px from the card’s corner, reaching 44 px around it and fading out at the edges; a few of them flicker.
5. **Size and clock**: The card’s size and the time since its blueprint began, counting up while it builds.
6. **Text run**: A dotted line as wide as the words themselves (read from a Range, not the box), with a knot at each end.
7. **Control outline**: A dotted box or ring at a control’s exact place and size. Labelled ones add their size and corner handles.
8. **Replay**: Builds every blueprint again, then reveals the cards in turn. Uncontrolled only; disabled while it builds.

## When to use

- A launch, a hero or a showcase where the arrival of the interface is the point: generated UI, a design tool, an AI that builds screens.
- A few cards (two to four) whose layout is known before their data, so the blueprint matches what replaces it.
- A wait long enough to notice (half a second or more), where a plain spinner would leave the space blank.
- Content that should arrive in reading order, one card after another, rather than all at once.

## When not to use

- Everyday loading in a product screen. Use a plain skeleton: grey blocks, no labels, no clock.
- Waits under a few hundred milliseconds: the build would only delay what is already there.
- Long lists or tables. The blueprint is measured per card, and dozens of them would crowd the page.
- Content whose size is unknown until it loads: the blueprint is drawn from the real card, so it needs one to measure.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/skeleton-build.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 { SkeletonReveal } from './skeleton-build';

export function Profile({ user }: { user?: User }) {
  return (
    <SkeletonReveal loading={!user} onRevealed={() => console.log('shown')}>
      <article className="card">
        <h3><span data-sk="text">{user?.name ?? 'Name Surname'}</span></h3>
        <img data-sk="box" data-sk-label="" src={user?.avatar} alt="" />
        <button data-sk="box">Follow</button>
      </article>
    </SkeletonReveal>
  );
}
```

## States

- **Blueprints**: `loading` true: every card held as its blueprint. The bracket, the clock and the flickering dots keep moving while on screen.
- **Revealing in turn**: The first card is in, the others still wait. The sweep itself (350 ms) can’t be held, so this is the stage between two cards.
- **Shown, controlled**: `loading` false: the cards are live (the heart rate drifts) and there is no Replay, since the parent decides.
- **Shown, uncontrolled**: With `autoplay` off the cards show at once, with Replay under them to watch the build.
- **Light and dark**: `colorScheme` fixes either scheme whatever the page says; left out, the stage follows the page. Every colour is a light-dark() token.

## How it works

- The card is rendered from the start, in its place: it sets the size of the whole, so nothing moves when it appears. While loading it is clipped away to nothing with clip-path, and it is inert, so neither the keyboard nor a screen reader reaches it.
- The blueprint is measured from the card itself. Every element marked data-sk ("text", "box" or "circle") becomes a dotted run, a dotted box with its corner radius or a dotted ring, at its exact place and size; a text run is the width of its words, read from a Range, not of its box. A ResizeObserver keeps it in step, and inside a zoomed or scaled preview the rectangles are brought back to the card’s own px.
- The dots are SVG strokes with a zero-length dash and round caps, so every dotted line is round dots at an even pitch; the grid behind is one radial-gradient tile, faded at its edges by two intersecting masks.
- The blueprint’s parts come in one by one in reading order over 900 ms, a bracket hops from part to part every 190 ms with its size (TEXT 41×15), and a few dots of the grid flicker darker. All of it is CSS animation except the bracket’s hop.
- The reveal is one phase change. The card’s clip-path opens from the top to its bottom edge while the scan line moves down by a transform, both on the same duration, delay and easing, so the line is always the clip’s edge. The card starts faint and blurred (85% opacity, 2 px) and sharpens as it shows; a band with a 5 px backdrop blur just above the line keeps the newest rows soft.
- The blueprint lies under the card, so the opaque card covers it as it shows and its dots come through while the card is still faint. Then the blueprint fades.
- Cards reveal stagger ms apart; the last one calls onRevealed. Loading again, each card blurs out over its blueprint, which builds itself again.
- Uncontrolled, the stage builds for 1.3 s once it is on screen, then reveals; Replay builds it again. While on screen and untouched, a demo pointer switches the chart’s period, plays and skips a track and likes it, then the stage builds again. Any press or key hands it to the reader.
- Three cards need 1098 px in a row: a narrower stage draws them at 80% or 66% (CSS zoom), and below that stacks them at their own size.
- Every animation rests while its card is off screen or the tab is hidden. Under reduced motion there is no sweep: the cards fade in.

## API reference

- **loading** (`boolean`): Controls the stage: true holds the blueprints, false reveals the cards. Without it, the stage builds itself and reveals on its own (SkeletonReveal requires it).
- **stagger** (`number`, default `375`): Ms between one card’s reveal and the next’s. On SkeletonReveal, the same is its delay prop.
- **duration** (`number`, default `350`): Ms each card’s sweep takes.
- **onRevealed** (`() => void`): Called once the last card is fully shown.
- **defaultRevealed** (`number`): Uncontrolled only: opens with this many cards shown and the rest held as blueprints, with no demo. Replay builds them all again and reveals them.
- **autoplay** (`boolean`, default `true`): Build on its own once on screen and let a demo pointer try the cards. False shows the cards at once (Replay still builds them).
- **colorScheme** (`'light' | 'dark' | 'system'`, default `'system'`): Forces a scheme, or follows the page’s color-scheme.
- **className** (`string`): Extra classes for the stage.

## Accessibility

- While loading, each card is inert and its wrapper aria-busy: the keyboard and screen readers skip what is not there yet. The blueprint and the scan line are aria-hidden.
- A polite live region says "Building the cards" and then "Cards ready".
- The cards are real controls: the period is a group of toggle buttons (aria-pressed), Details and the likes list are disclosures (aria-expanded, aria-controls), the like is a toggle, playback is a progressbar with its time as text, and the chart is an image with every bar in its label.
- Focus shows as a blue ring on every control; the demo pointer is decorative and stops at the first press or key.
- Under reduced motion there is no sweep, no blur and no flicker: the cards fade in.

## Source

### skeleton-build/skeleton-build.tsx

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

'use client';

import { useEffect, useEffectEvent, useRef, useState, type CSSProperties } from 'react';
import { HeartCard, PlayerCard, TokenCard } from './cards';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { hideDelay, SkeletonReveal } from './skeleton-reveal';
import { useAutoplay } from './use-autoplay';
import styles from './skeleton-build.module.css';

export { SkeletonReveal, type SkeletonRevealProps } from './skeleton-reveal';

/*
 * Skeleton build: cards that are drawn first as blueprints, then swept in one after another, as
 * if an interface were being generated in front of the reader.
 *
 * - Each card sits in a SkeletonReveal (skeleton-reveal.tsx), which measures it and draws its
 *   blueprint: dotted runs for its words, dotted boxes and rings for its controls, its outline with
 *   corner handles, guides and size, over a grid of dots. On reveal, a scan line sweeps it in.
 * - The cards reveal `stagger` ms apart, in reading order; the last one calls onRevealed.
 * - Uncontrolled (no loading prop), the stage builds its blueprints for BUILD ms once it is on
 *   screen, then reveals; Replay builds it all again. Controlled, it shows what `loading` says.
 * - Once shown, the cards are live: the heart rate drifts, the chart switches period, the player
 *   plays. While on screen and untouched, a demo pointer tries them, then the stage builds itself
 *   again; any press or key hands it to the reader. autoplay={false} shows the cards at once.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** How long the blueprints build before the reveal starts, in ms. */
const BUILD = 1300;
/** How long a card takes to blur out before its blueprint builds again (skeleton-reveal.tsx). */
const HIDE = 300;

type Props = {
  className?: string;
  /** Play the build on its own and let a demo pointer try the cards (default true). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  /** Controls the stage: true holds the blueprints, false reveals the cards. */
  loading?: boolean;
  /** Ms between one card's reveal and the next's. */
  stagger?: number;
  /** Ms each card's sweep takes. */
  duration?: number;
  /** Called once the last card is fully shown. */
  onRevealed?: () => void;
  /**
   * Uncontrolled only: opens with this many cards shown and the rest held as blueprints, with no
   * demo (autoplay off). Replay builds them all again and reveals them all.
   */
  defaultRevealed?: number;
};

export function SkeletonBuild({
  className,
  autoplay = true,
  colorScheme = 'system',
  loading,
  stagger = 375,
  duration = 350,
  onRevealed,
  defaultRevealed,
}: Props) {
  const { ref: stage, take, playing, visible, reduced } = useAutoplay<HTMLDivElement>(8000);
  const pointer = useRef<HTMLSpanElement>(null);
  const controlled = loading !== undefined;
  // Opened part-way (defaultRevealed), the stage holds still until Replay, and plays no demo.
  const [held, setHeld] = useState(!controlled && defaultRevealed !== undefined);
  const auto = autoplay && (controlled || defaultRevealed === undefined);
  // Uncontrolled, the stage starts as blueprints only when it is going to play them.
  const [building, setBuilding] = useState(auto && !controlled);
  const [round, setRound] = useState(0);
  const [shown, setShown] = useState(
    controlled ? !loading : !auto && (!held || (defaultRevealed ?? 0) >= 3),
  );
  const isLoading = controlled ? loading : building;
  const live = visible && !isLoading;

  // Uncontrolled: the blueprints build while on screen, then the cards reveal.
  useEffect(() => {
    if (controlled || !building || !visible) return;
    const timer = window.setTimeout(() => setBuilding(false), reduced ? 0 : BUILD);
    return () => window.clearTimeout(timer);
  }, [controlled, building, visible, reduced, round]);

  useEffect(() => {
    if (isLoading) setShown(false);
  }, [isLoading]);

  const revealed = () => {
    setShown(true);
    onRevealed?.();
  };

  /** Builds the whole stage again, its cards fresh once they have blurred out. */
  const rebuild = () => {
    setBuilding(true);
    // The cards blur out a little apart (hideDelay); the last one is out before they remount.
    window.setTimeout(() => setRound((value) => value + 1), HIDE + hideDelay(2 * stagger));
  };

  // The demo: once the cards are shown, the pointer tries them, then the stage builds again.
  const script = useEffectEvent(async (demo: Demo) => {
    const element = stage.current;
    if (!element) return;
    const at = (selector: string) => {
      const target = element.querySelector<HTMLElement>(selector);
      if (!target) return null;
      const box = element.getBoundingClientRect();
      const scale = box.width / element.offsetWidth || 1;
      const rect = target.getBoundingClientRect();
      return {
        target,
        x: (rect.left + rect.width / 2 - box.left) / scale,
        y: (rect.top + rect.height / 2 - box.top) / scale,
      };
    };
    const press = async (selector: string, rest: number) => {
      const spot = at(selector);
      // A control off the stage's visible part (a narrow stage stacks the cards) is skipped.
      if (!spot || spot.y > element.offsetHeight || spot.y < 0) return;
      await demo.glide(spot, 620);
      demo.glyph('hand');
      await demo.wait(220);
      spot.target.click();
      await demo.wait(rest);
      demo.glyph('arrow');
    };
    await demo.wait(700);
    await press('[data-period="month"]', 900);
    await press('[data-period="year"]', 1100);
    await press('[data-period="week"]', 500);
    await press('[data-control="play"]', 1500);
    await press('[data-control="next"]', 1600);
    await press('[aria-label^="Like"]', 900);
    await press('[data-control="play"]', 500);
    await demo.glide({ x: element.offsetWidth * 0.88, y: element.offsetHeight * 0.96 }, 700);
    await demo.wait(900);
    if (!controlled) rebuild();
  });

  useEffect(() => {
    const element = stage.current;
    const hand = pointer.current;
    if (!auto || !playing || !shown || !element || !hand) return;
    return runDemo(element, hand, script);
  }, [auto, playing, shown, stage]);

  const replay = () => {
    take();
    setHeld(false);
    rebuild();
  };

  const style = { '--stagger': `${stagger}ms` } as CSSProperties;
  const cards = [
    <HeartCard key="heart" live={live} />,
    <TokenCard key="tokens" />,
    <PlayerCard key="player" live={live} />,
  ];

  return (
    <div
      ref={stage}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme === 'system' ? undefined : colorScheme}
      data-part="stage"
      style={style}
      onPointerDown={(event) => {
        if (event.isPrimary) take();
      }}
      onKeyDown={take}
    >
      <div className={styles.cards} data-part="cards">
        {cards.map((card, index) => (
          <SkeletonReveal
            key={`${card.key}-${round}`}
            loading={isLoading || (held && index >= (defaultRevealed ?? 0))}
            delay={index * stagger}
            duration={duration}
            onRevealed={index === cards.length - 1 ? revealed : undefined}
          >
            {card}
          </SkeletonReveal>
        ))}
      </div>
      {!controlled && (
        <button
          type="button"
          className={styles.replay}
          onClick={replay}
          disabled={isLoading}
          data-part="replay"
        >
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <path d="M3.5 8a4.5 4.5 0 1 0 1.4-3.3M3.5 2.8v2.4h2.4" />
          </svg>
          Replay
        </button>
      )}
      <p className={styles.hidden} aria-live="polite">
        {isLoading ? 'Building the cards' : shown ? 'Cards ready' : ''}
      </p>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}
```

### skeleton-build/skeleton-reveal.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from 'react';
import styles from './skeleton-build.module.css';

/*
 * Skeleton reveal: one card drawn first as its own blueprint, then swept into the real thing.
 *
 * - The card is rendered from the start, in its place, so it sets the size of the whole and nothing
 *   moves when it appears. While loading it is clipped away to nothing.
 * - The blueprint is measured from the card itself: every element marked data-sk ("text", "box" or
 *   "circle") becomes a dotted run, a dotted outline or a dotted ring at its exact place and size,
 *   over a grid of dots, inside the card's own dotted outline with its corner handles and guides.
 *   A text run is the width of its words, read from a Range, not of its box. Parts come in one by
 *   one in reading order, a bracket hops from part to part naming what it places ("TEXT 41×15")
 *   and where ("126·394"), the card's size label counts the time up, and dots of the grid flicker.
 * - Revealing, a dotted scan line runs down the card, fast at first and settling (an ease-out).
 *   Above it the card shows (its clip-path opens to the line, on the same easing and clock as the
 *   line's transform); the band just above the line is blurred and pale (a backdrop blur), so rows
 *   come into focus a moment after the line passes. The blueprint lies under the card, so the card
 *   covers it as it shows. Then the blueprint fades.
 * - Loading again, the card blurs out over its blueprint, which builds itself again.
 * - It all runs on CSS transitions and animations; JavaScript only measures, flips the phase and
 *   moves the bracket. The animations rest while the card is off screen or the tab is hidden, and
 *   under reduced motion the card simply fades in.
 */

type Phase = 'building' | 'sweeping' | 'shown' | 'hiding';
type Kind = 'text' | 'box' | 'circle';
type Part = {
  kind: Kind;
  x: number;
  y: number;
  w: number;
  h: number;
  r: number;
  /** What the bracket calls it: TEXT, or the element's data-sk-tag. */
  tag: string;
  /** Whether it shows its size and corner handles (data-sk-label). */
  label: boolean;
};
type Blueprint = { w: number; h: number; r: number; parts: Part[] };

/** How long the card takes to blur out before its blueprint builds again, in ms. */
const HIDE = 280;
/** Blurring out, the cards go a quarter of their reveal stagger apart. */
export const hideDelay = (delay: number) => Math.round(delay / 4);
/** How long the blueprint's parts take to come in, all of them, in ms. */
const BUILD = 900;
/** How often the bracket moves to the next part, in ms. */
const HOP = 190;
/** How far the grid of dots reaches around the card, in px. */
const MARGIN = 44;
/** The grid's pitch: a dot every GRID px from the card's corner (the CSS draws the same). */
const GRID = 15;

const pad = (value: number) => String(Math.max(0, Math.round(value))).padStart(3, '0');

const round = (value: number) => Math.round(value);

/** Reads the blueprint of the card inside `content`, in the card's own (unscaled) px. */
function measure(content: HTMLElement): Blueprint | null {
  const card = content.firstElementChild as HTMLElement | null;
  if (!card) return null;
  const own = parseFloat(getComputedStyle(content).width);
  const base = content.getBoundingClientRect();
  if (!own || !base.width) return null;
  // Zoomed or inside a scaled preview, the rectangles come scaled: bring them back to the card's
  // own CSS px, which the computed width is in.
  const scale = base.width / own;
  const range = document.createRange();
  const parts: Part[] = [];
  card.querySelectorAll<HTMLElement>('[data-sk]').forEach((element) => {
    const kind = element.dataset.sk as Kind;
    let rect: DOMRect;
    if (kind === 'text') {
      range.selectNodeContents(element);
      rect = range.getBoundingClientRect();
    } else rect = element.getBoundingClientRect();
    if (!rect.width || !rect.height) return;
    const w = rect.width / scale;
    const h = rect.height / scale;
    const radius = parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0;
    parts.push({
      kind,
      x: (rect.left - base.left) / scale,
      y: (rect.top - base.top) / scale,
      w,
      h,
      r: Math.min(radius, w / 2, h / 2),
      tag: element.dataset.skTag ?? (kind === 'text' ? 'TEXT' : kind === 'circle' ? 'ICON' : 'BOX'),
      label: element.hasAttribute('data-sk-label'),
    });
  });
  parts.sort((a, b) => a.y - b.y || a.x - b.x);
  const box = getComputedStyle(card);
  return {
    w: parseFloat(box.width),
    h: parseFloat(box.height),
    r: parseFloat(getComputedStyle(card).borderTopLeftRadius) || 0,
    parts,
  };
}

/** A few dots of the grid, inside the card, that flicker darker: the same ones for a given size. */
function flickers(w: number, h: number, count = 34) {
  let seed = (round(w) * 73856093) ^ (round(h) * 19349663);
  const next = () => {
    seed = (seed * 1103515245 + 12345) & 0x7fffffff;
    return seed / 0x7fffffff;
  };
  const columns = Math.max(1, Math.floor(w / GRID) - 1);
  const rows = Math.max(1, Math.floor(h / GRID) - 1);
  return Array.from({ length: count }, () => ({
    x: GRID + Math.floor(next() * columns) * GRID,
    y: GRID + Math.floor(next() * rows) * GRID,
    delay: round(next() * 2400),
  }));
}

/** The bracket that hops from part to part while the blueprint builds, naming each one. */
function Bracket({ parts, active }: { parts: Part[]; active: boolean }) {
  const named = parts.filter((part) => part.kind === 'text' || part.label);
  const [index, setIndex] = useState(0);
  useEffect(() => {
    if (!active || named.length < 2) return;
    const timer = window.setInterval(() => setIndex((value) => value + 1), HOP);
    return () => window.clearInterval(timer);
  }, [active, named.length]);
  const part = named[index % Math.max(1, named.length)];
  if (!part) return null;
  const x = part.x - 4;
  const y = part.y - 4;
  const w = part.w + 8;
  const h = part.h + 8;
  const arm = Math.min(7, w / 3, h / 3);
  return (
    <g className={styles.bracket} data-part="bracket">
      <path
        d={[
          `M${x} ${y + arm}V${y}H${x + arm}`,
          `M${x + w - arm} ${y}H${x + w}V${y + arm}`,
          `M${x + w} ${y + h - arm}V${y + h}H${x + w - arm}`,
          `M${x + arm} ${y + h}H${x}V${y + h - arm}`,
        ].join('')}
      />
      <text x={x} y={y - 4}>
        {`${part.tag} ${round(part.w)}×${round(part.h)}`}
      </text>
      <text className={styles.bracketAt} x={x + w - 6} y={y + 11}>
        {`${pad(part.x)}·${pad(part.y)}`}
      </text>
    </g>
  );
}

/**
 * The card's size and a clock above its corner ("332 × 345 t+1.89s"): the time since its
 * blueprint began, written straight into the text node while it builds (no re-render).
 */
function SizeLabel({ w, h, active }: { w: number; h: number; active: boolean }) {
  const text = useRef<SVGTextElement>(null);
  const start = useRef(0);
  const size = `${round(w)} × ${round(h)}`;
  useEffect(() => {
    const node = text.current;
    if (!node) return;
    if (!start.current) start.current = performance.now();
    const write = () => {
      node.textContent = `${size} t+${((performance.now() - start.current) / 1000).toFixed(2)}s`;
    };
    write();
    if (!active) return;
    const timer = window.setInterval(write, 40);
    return () => window.clearInterval(timer);
  }, [active, size]);
  return (
    <text ref={text} className={styles.sizeLabel} x={0} y={-10} data-part="size-label">
      {`${size} t+0.00s`}
    </text>
  );
}

/** Four small hollow squares on the corners of a box. */
function Handles({
  x,
  y,
  w,
  h,
  size,
}: {
  x: number;
  y: number;
  w: number;
  h: number;
  size: number;
}) {
  const half = size / 2;
  return (
    <>
      {[
        [x, y],
        [x + w, y],
        [x, y + h],
        [x + w, y + h],
      ].map(([cx, cy], index) => (
        <rect
          key={index}
          className={styles.handle}
          x={cx - half}
          y={cy - half}
          width={size}
          height={size}
        />
      ))}
    </>
  );
}

/** One part of the blueprint: a dotted run of text, a dotted box or a dotted ring. */
function PartDrawing({ part, index }: { part: Part; index: number }) {
  const { x, y, w, h, r } = part;
  const style = { '--i': index } as CSSProperties;
  if (part.kind === 'text') {
    const middle = y + h / 2;
    // A knot partway along longer runs, as where a word ends.
    const knot = w > 60 ? x + w * (0.35 + ((index * 37) % 30) / 100) : null;
    return (
      <g className={styles.part} style={style} data-part="sk-text">
        <line className={styles.dots} x1={x} y1={middle} x2={x + w} y2={middle} />
        <circle className={styles.knot} cx={x} cy={middle} r={2.1} />
        <circle className={styles.knot} cx={x + w} cy={middle} r={2.1} />
        {knot !== null && <circle className={styles.knot} cx={knot} cy={middle} r={1.7} />}
      </g>
    );
  }
  return (
    <g className={styles.part} style={style} data-part={`sk-${part.kind}`}>
      {part.kind === 'circle' ? (
        <ellipse className={styles.dots} cx={x + w / 2} cy={y + h / 2} rx={w / 2} ry={h / 2} />
      ) : (
        <rect className={styles.dots} x={x} y={y} width={w} height={h} rx={r} />
      )}
      {part.label && (
        <>
          <Handles x={x} y={y} w={w} h={h} size={4.5} />
          <text className={styles.partLabel} x={x} y={y - 5}>
            {`${round(w)} × ${round(h)}`}
          </text>
        </>
      )}
    </g>
  );
}

export type SkeletonRevealProps = {
  /** True shows the blueprint; false sweeps the card in. */
  loading: boolean;
  /** How long after loading turns false the sweep starts, in ms. */
  delay?: number;
  /** How long the sweep takes, in ms. */
  duration?: number;
  /** Called once the card is fully shown. */
  onRevealed?: () => void;
  /** The card: a single element, with data-sk on the parts its blueprint draws. */
  children: ReactNode;
  className?: string;
};

/**
 * Wraps one card: while `loading`, its measured blueprint shows in its place; when `loading`
 * turns false, a scan line sweeps the card in, `delay` ms later, in `duration` ms.
 */
export function SkeletonReveal({
  loading,
  delay = 0,
  duration = 350,
  onRevealed,
  children,
  className,
}: SkeletonRevealProps) {
  const root = useRef<HTMLDivElement>(null);
  const content = useRef<HTMLDivElement>(null);
  const [phase, setPhase] = useState<Phase>(loading ? 'building' : 'shown');
  const [blueprint, setBlueprint] = useState<Blueprint | null>(null);
  const [build, setBuild] = useState(0);
  const [onScreen, setOnScreen] = useState(true);
  const phaseNow = useRef(phase);
  useLayoutEffect(() => {
    phaseNow.current = phase;
  }, [phase]);

  const revealed = useEffectEvent(() => onRevealed?.());

  // Loading on: blur the card out, then build its blueprint again. Loading off: sweep it in.
  useEffect(() => {
    if (loading) {
      if (phaseNow.current === 'building') return;
      setPhase('hiding');
      const timer = window.setTimeout(
        () => {
          setPhase('building');
          setBuild((value) => value + 1);
        },
        HIDE + hideDelay(delay),
      );
      return () => window.clearTimeout(timer);
    }
    if (phaseNow.current === 'shown') return;
    setPhase('sweeping');
    const timer = window.setTimeout(() => {
      setPhase('shown');
      revealed();
    }, delay + duration);
    return () => window.clearTimeout(timer);
    // A sweep keeps the delay and duration it started with.
  }, [loading]);

  // The blueprint follows the card's size while it can be seen.
  useLayoutEffect(() => {
    const element = content.current;
    if (!element || phase === 'shown') return;
    const read = () => {
      const next = measure(element);
      if (next) setBlueprint(next);
    };
    read();
    const observer = new ResizeObserver(read);
    observer.observe(element);
    return () => observer.disconnect();
  }, [phase, build]);

  // Its animations rest while it is off screen or the tab is hidden.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let visible = true;
    const update = () => setOnScreen(visible && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, []);

  const building = phase === 'building' || phase === 'hiding';
  const parts = blueprint?.parts ?? [];
  const w = blueprint?.w ?? 0;
  const h = blueprint?.h ?? 0;
  const style = {
    '--delay': `${delay}ms`,
    '--duration': `${duration}ms`,
    '--hide-delay': `${hideDelay(delay)}ms`,
    '--step': `${parts.length ? BUILD / parts.length : 0}ms`,
    '--margin': `${MARGIN}px`,
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.reveal} ${className ?? ''}`}
      data-part="reveal"
      data-phase={phase}
      data-paused={onScreen ? undefined : ''}
      aria-busy={phase !== 'shown'}
      style={style}
    >
      <div className={styles.wire} aria-hidden="true">
        <span className={styles.grid} data-part="grid" />
        {blueprint && (
          <svg
            className={styles.wireDrawing}
            data-part="blueprint"
            width={w}
            height={h}
            viewBox={`0 0 ${w} ${h}`}
            key={build}
          >
            <line className={styles.guide} x1={-MARGIN} y1={0} x2={w + MARGIN} y2={0} />
            <line className={styles.guide} x1={-MARGIN} y1={h} x2={w + MARGIN} y2={h} />
            <line className={styles.guide} x1={0} y1={-MARGIN} x2={0} y2={h + MARGIN} />
            <line className={styles.guide} x1={w} y1={-MARGIN} x2={w} y2={h + MARGIN} />
            <rect
              className={styles.outline}
              x={0.75}
              y={0.75}
              width={w - 1.5}
              height={h - 1.5}
              rx={blueprint.r}
            />
            <Handles x={0} y={0} w={w} h={h} size={7} />
            <SizeLabel w={w} h={h} active={phase !== 'shown' && onScreen} />
            {flickers(w, h).map((dot, index) => (
              <circle
                key={index}
                className={styles.flicker}
                cx={dot.x}
                cy={dot.y}
                r={1.9}
                style={{ animationDelay: `${dot.delay}ms` }}
              />
            ))}
            {parts.map((part, index) => (
              <PartDrawing key={index} part={part} index={index} />
            ))}
            <Bracket parts={parts} active={building && onScreen} />
          </svg>
        )}
      </div>
      <div ref={content} className={styles.content} inert={building} data-part="content">
        {children}
      </div>
      <div className={styles.sweep} aria-hidden="true" data-part="sweep">
        <span className={styles.sweepVeil} />
        <span className={styles.sweepLine} />
      </div>
    </div>
  );
}
```

### skeleton-build/cards.tsx

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

'use client';

import { useEffect, useId, useState, type CSSProperties } from 'react';
import styles from './skeleton-build.module.css';

/*
 * The three sample cards the stage builds: a heart-rate monitor, a stacked bar chart of token
 * spend and a music player. All the content is invented. Every part a blueprint should draw is
 * marked data-sk ("text", "box" or "circle"; data-sk-label adds its size and corner handles);
 * the main parts also carry data-part, for the docs.
 *
 * Each card works on its own once shown: the heart rate drifts and its trace runs, the chart
 * switches between week, month and year and opens its details, the player plays, skips, likes
 * and lists who liked. `live` stops anything that runs on a clock (off screen, hidden tab, or
 * while the card is still a blueprint).
 */

/* Heart rate ---------------------------------------------------------------------------------- */

/** The readings the monitor drifts through, one every BEAT ms. */
const READINGS = [72, 74, 77, 76, 73, 75];
const BEAT = 1900;

/** A number whose changed digits roll in, blurred, from below. */
function Digits({ value }: { value: string }) {
  return (
    <span className={styles.digits} aria-hidden="true">
      {value.split('').map((char, index) => (
        <span key={`${index}-${char}`} className={styles.digit}>
          {char}
        </span>
      ))}
    </span>
  );
}

function Icon({ name }: { name: 'back' | 'more' | 'chevron' }) {
  if (name === 'back')
    return (
      <svg viewBox="0 0 16 16" className={styles.icon} aria-hidden="true">
        <path d="M9.5 4 5.5 8l4 4" />
      </svg>
    );
  if (name === 'more')
    return (
      <svg viewBox="0 0 16 16" className={styles.icon} aria-hidden="true">
        <circle cx="4" cy="8" r="1.2" />
        <circle cx="8" cy="8" r="1.2" />
        <circle cx="12" cy="8" r="1.2" />
      </svg>
    );
  return (
    <svg viewBox="0 0 16 16" className={styles.icon} aria-hidden="true">
      <path d="m4.5 6.5 3.5 3.5 3.5-3.5" />
    </svg>
  );
}

export function HeartCard({ live }: { live: boolean }) {
  const [reading, setReading] = useState(0);
  const [unit, setUnit] = useState<'bpm' | 'zone'>('bpm');
  useEffect(() => {
    if (!live) return;
    const timer = window.setInterval(
      () => setReading((value) => (value + 1) % READINGS.length),
      BEAT,
    );
    return () => window.clearInterval(timer);
  }, [live]);
  const bpm = READINGS[reading];
  return (
    <article
      className={`${styles.card} ${styles.heart}`}
      data-part="heart-card"
      aria-label="Heart rate"
    >
      <header className={styles.heartTop}>
        <button
          type="button"
          className={styles.roundButton}
          aria-label="Back"
          data-sk="circle"
          data-sk-label=""
        >
          <Icon name="back" />
        </button>
        <h3 className={styles.heartTitle}>
          <span data-sk="text">Heart rate</span>
        </h3>
        <button
          type="button"
          className={styles.roundButton}
          aria-label={unit === 'bpm' ? 'Show the zone' : 'Show beats per minute'}
          data-sk="circle"
          data-sk-label=""
          onClick={() => setUnit((value) => (value === 'bpm' ? 'zone' : 'bpm'))}
        >
          <Icon name="more" />
        </button>
      </header>
      <p className={styles.reading} data-part="reading">
        <span data-sk="text" className={styles.readingValue}>
          <Digits value={unit === 'bpm' ? String(bpm) : bpm > 75 ? 'Z2' : 'Z1'} />
        </span>
        <span className={styles.hidden}>
          {unit === 'bpm' ? `${bpm} beats per minute` : `zone ${bpm > 75 ? 2 : 1}`}
        </span>
        <span data-sk="text" className={styles.readingUnit} aria-hidden="true">
          {unit === 'bpm' ? 'bpm' : 'zone'}
        </span>
      </p>
      <div className={styles.trace} data-part="trace" data-live={live ? '' : undefined}>
        <svg viewBox="0 0 300 96" preserveAspectRatio="none" aria-hidden="true">
          <path
            pathLength={100}
            d="M0 58H40l6-4 5 4h18l5-44 8 66 6-22h52l6-4 5 4h18l5-44 8 66 6-22h52l6-4 5 4h18l5-44 8 66 6-22H300"
          />
        </svg>
      </div>
      <h4 className={styles.statsTitle}>
        <span data-sk="text">Recovery</span>
      </h4>
      <dl className={styles.stats}>
        {[
          ['Current', '1.6', 'min'],
          ['Average', '2.2', 'min'],
          ['Max', '4.2', 'min'],
        ].map(([label, value, suffix]) => (
          <div key={label}>
            <dt>
              <span data-sk="text">{label}</span>
            </dt>
            <dd>
              <span data-sk="text">
                {value} <span className={styles.muted}>{suffix}</span>
              </span>
            </dd>
          </div>
        ))}
      </dl>
    </article>
  );
}

/* Token spend --------------------------------------------------------------------------------- */

type Period = 'week' | 'month' | 'year';
const SERIES = ['Kestrel Large', 'Kestrel Mini', 'Embeddings', 'Vision'];
/** Per bar, each series' spend, in thousands of tokens. */
const SPEND: Record<Period, { range: string; labels: string[]; values: number[][] }> = {
  week: {
    range: 'Jul 1 – 7',
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    values: [
      [80, 95, 100, 125, 95, 75, 70],
      [55, 80, 68, 90, 70, 62, 55],
      [42, 40, 50, 48, 45, 38, 37],
      [30, 35, 33, 48, 40, 30, 24],
    ],
  },
  month: {
    range: 'July',
    labels: ['1', '5', '10', '15', '20', '25', '30'],
    values: [
      [330, 370, 400, 460, 420, 430, 390],
      [230, 250, 280, 300, 290, 290, 260],
      [160, 170, 190, 200, 195, 200, 185],
      [110, 120, 130, 140, 135, 140, 125],
    ],
  },
  year: {
    range: '2026',
    labels: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'],
    values: [
      [1600, 1700, 1900, 2000, 2100, 2200, 2800, 2400, 2300, 2400, 2500, 2500],
      [1100, 1200, 1300, 1400, 1400, 1500, 1900, 1700, 1600, 1700, 1700, 1700],
      [700, 800, 900, 900, 1000, 1000, 1200, 1100, 1000, 1100, 1100, 1100],
      [500, 600, 600, 700, 700, 700, 900, 800, 700, 800, 800, 700],
    ],
  },
};
const PERIODS: { id: Period; label: string; compare: string }[] = [
  { id: 'week', label: 'Week', compare: 'last week' },
  { id: 'month', label: 'Month', compare: 'June' },
  { id: 'year', label: 'Year', compare: '2025' },
];
const CHANGE: Record<Period, string> = { week: '+12%', month: '+8%', year: '+31%' };

/** Thousands of tokens, as the card writes them: 640K, 2.8M. */
function tokens(thousands: number) {
  if (thousands >= 1000) return `${(thousands / 1000).toFixed(1).replace(/\.0$/, '')}M`;
  return `${Math.round(thousands)}K`;
}

export function TokenCard() {
  const [period, setPeriod] = useState<Period>('week');
  const [open, setOpen] = useState(false);
  const panel = useId();
  const data = SPEND[period];
  const totals = data.labels.map((_, bar) =>
    data.values.reduce((sum, series) => sum + series[bar], 0),
  );
  const max = Math.max(...totals);
  const sums = data.values.map((series) => series.reduce((sum, value) => sum + value, 0));
  const total = sums.reduce((sum, value) => sum + value, 0);
  const busiest = totals.indexOf(max);
  return (
    <article
      className={`${styles.card} ${styles.tokens}`}
      data-part="token-card"
      aria-label="Token spend"
    >
      <h3 className={styles.cardTitle}>
        <span data-sk="text">Token spend</span>
      </h3>
      <p className={styles.subtitle}>
        <span data-sk="text">{data.range}</span>
      </p>
      <div className={styles.segmented} role="group" aria-label="Period" data-part="periods">
        {PERIODS.map((item) => (
          <button
            key={item.id}
            type="button"
            aria-pressed={period === item.id}
            data-sk="box"
            data-sk-label=""
            data-period={item.id}
            onClick={() => setPeriod(item.id)}
          >
            {item.label}
          </button>
        ))}
      </div>
      <hr className={styles.rule} />
      <p className={styles.total} aria-live="polite">
        <span data-sk="text" key={period} className={styles.swap}>
          {tokens(total)}
        </span>{' '}
        <span data-sk="text" className={styles.muted}>
          Tokens
        </span>
      </p>
      <div
        className={styles.bars}
        data-part="chart"
        data-count={data.labels.length}
        role="img"
        aria-label={`Spend per ${period === 'year' ? 'month' : 'day'}, ${data.labels
          .map((label, bar) => `${label} ${tokens(totals[bar])}`)
          .join(', ')}`}
      >
        {data.labels.map((label, bar) => (
          <div key={bar} className={styles.barColumn}>
            <div className={styles.bar} style={{ height: `${(totals[bar] / max) * 100}%` }}>
              {data.values.map((series, index) => (
                <span
                  key={index}
                  data-sk="box"
                  data-series={index}
                  style={{ flexGrow: series[bar] } as CSSProperties}
                />
              ))}
            </div>
            <span className={styles.barLabel} aria-hidden="true">
              {label}
            </span>
          </div>
        ))}
      </div>
      <ul className={styles.legend} data-part="legend">
        {SERIES.map((name, index) => (
          <li key={name}>
            <span className={styles.swatch} data-series={index} data-sk="circle" />
            <span data-sk="text">{name}</span>
            <span data-sk="text" className={styles.legendValue} key={`${period}-${index}`}>
              {tokens(sums[index])}
            </span>
          </li>
        ))}
      </ul>
      <button
        type="button"
        className={styles.details}
        aria-expanded={open}
        aria-controls={panel}
        data-sk="box"
        data-part="details"
        onClick={() => setOpen((value) => !value)}
      >
        Details
        <Icon name="chevron" />
      </button>
      <div id={panel} className={styles.detailsPanel} data-open={open ? '' : undefined}>
        <div inert={!open}>
          <dl>
            <div>
              <dt>Busiest</dt>
              <dd>
                {data.labels[busiest]} · {tokens(max)}
              </dd>
            </div>
            <div>
              <dt>Average</dt>
              <dd>{tokens(total / data.labels.length)}</dd>
            </div>
            <div>
              <dt>vs {PERIODS.find((item) => item.id === period)?.compare}</dt>
              <dd>{CHANGE[period]}</dd>
            </div>
          </dl>
        </div>
      </div>
    </article>
  );
}

/* Player -------------------------------------------------------------------------------------- */

const TRACKS = [
  {
    title: 'Night Ferry',
    artist: 'Iris Calder',
    length: 245,
    likes: 9,
    likers: ['RL', 'OF', 'MC'],
    names: 'Rhea Lund, Omar Faris, Mina Cho and 6 more',
    art: 'dawn',
  },
  {
    title: 'Paper Moons',
    artist: 'Juno Hale',
    length: 204,
    likes: 24,
    likers: ['MI', 'LF', 'SI', 'OR'],
    names: 'Mae Ito, Leo Fenn, Sana Iqbal, Oren Roth and 20 more',
    art: 'dusk',
  },
];

const clock = (seconds: number) =>
  `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`;

function Glyph({ name }: { name: 'play' | 'pause' | 'previous' | 'next' | 'heart' }) {
  const paths = {
    play: <path d="M6 4.2v7.6a.6.6 0 0 0 .9.5l6-3.8a.6.6 0 0 0 0-1L6.9 3.7a.6.6 0 0 0-.9.5Z" />,
    pause: (
      <>
        <rect x="4.5" y="3.5" width="2.6" height="9" rx="0.8" />
        <rect x="8.9" y="3.5" width="2.6" height="9" rx="0.8" />
      </>
    ),
    previous: <path d="M8 5v6L3 8Zm5.5 0v6l-5-3Z" strokeLinejoin="round" />,
    next: <path d="M8 5v6l5-3Zm-5.5 0v6l5-3Z" strokeLinejoin="round" />,
    heart: (
      <path d="M8 13.2S2.5 9.9 2.5 6.1A2.9 2.9 0 0 1 8 4.8a2.9 2.9 0 0 1 5.5 1.3c0 3.8-5.5 7.1-5.5 7.1Z" />
    ),
  };
  return (
    <svg viewBox="0 0 16 16" className={styles.glyph} data-glyph={name} aria-hidden="true">
      {paths[name]}
    </svg>
  );
}

export function PlayerCard({ live }: { live: boolean }) {
  const [track, setTrack] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [position, setPosition] = useState(0);
  const [liked, setLiked] = useState<boolean[]>(TRACKS.map(() => false));
  const [open, setOpen] = useState(false);
  const list = useId();
  const song = TRACKS[track];
  const go = (step: number) => {
    setTrack((value) => (value + step + TRACKS.length) % TRACKS.length);
    setPosition(0);
  };
  useEffect(() => {
    if (!live || !playing) return;
    const timer = window.setInterval(() => setPosition((value) => value + 1), 1000);
    return () => window.clearInterval(timer);
  }, [live, playing]);
  // At the end of a track, the next one starts.
  if (position >= song.length) go(1);
  const likes = song.likes + (liked[track] ? 1 : 0);
  return (
    <article
      className={`${styles.card} ${styles.player}`}
      data-part="player-card"
      data-art={song.art}
      aria-label="Music player"
    >
      <div
        className={styles.art}
        data-sk="box"
        data-sk-tag="IMAGE"
        data-sk-label=""
        data-part="art"
      >
        <span key={song.art} className={styles.artImage} data-art={song.art} />
      </div>
      <div className={styles.song}>
        <div key={track} className={styles.swap}>
          <h3 className={styles.cardTitle}>
            <span data-sk="text">{song.title}</span>
          </h3>
          <p className={styles.subtitle}>
            <span data-sk="text">{song.artist}</span>
          </p>
        </div>
        <button
          type="button"
          className={styles.like}
          aria-pressed={liked[track]}
          aria-label={`Like ${song.title}`}
          data-sk="circle"
          data-sk-tag="LIKE"
          data-sk-label=""
          onClick={() =>
            setLiked((value) => value.map((item, index) => (index === track ? !item : item)))
          }
        >
          <Glyph name="heart" />
        </button>
      </div>
      <div className={styles.progress} data-part="progress">
        <div
          className={styles.track}
          data-sk="box"
          role="progressbar"
          aria-label="Played"
          aria-valuemin={0}
          aria-valuemax={song.length}
          aria-valuenow={Math.min(position, song.length)}
          aria-valuetext={`${clock(position)} of ${clock(song.length)}`}
        >
          <span style={{ transform: `scaleX(${Math.min(1, position / song.length)})` }} />
        </div>
        <div className={styles.times} aria-hidden="true">
          <span data-sk="text">{clock(position)}</span>
          <span data-sk="text">{clock(song.length)}</span>
        </div>
      </div>
      <div className={styles.controls} data-part="controls">
        <button
          type="button"
          className={styles.skip}
          aria-label="Previous track"
          data-sk="circle"
          data-sk-label=""
          data-control="previous"
          onClick={() => go(-1)}
        >
          <Glyph name="previous" />
        </button>
        <button
          type="button"
          className={styles.play}
          aria-label={playing ? 'Pause' : 'Play'}
          data-sk="circle"
          data-sk-label=""
          data-control="play"
          onClick={() => setPlaying((value) => !value)}
        >
          <Glyph name={playing ? 'pause' : 'play'} />
        </button>
        <button
          type="button"
          className={styles.skip}
          aria-label="Next track"
          data-sk="circle"
          data-sk-label=""
          data-control="next"
          onClick={() => go(1)}
        >
          <Glyph name="next" />
        </button>
      </div>
      <div className={styles.footer}>
        <span className={styles.avatars} aria-hidden="true">
          {song.likers.map((initials, index) => (
            <span key={`${track}-${initials}`} data-sk="circle" data-tone={index % 3}>
              {initials}
            </span>
          ))}
        </span>
        <span data-sk="text" className={styles.likes}>
          {likes} likes
        </span>
        <button
          type="button"
          className={styles.more}
          aria-label="Who liked it"
          aria-expanded={open}
          aria-controls={list}
          data-sk="box"
          data-sk-tag="SVG"
          data-sk-label=""
          onClick={() => setOpen((value) => !value)}
        >
          <Icon name="chevron" />
        </button>
      </div>
      <div id={list} className={styles.detailsPanel} data-open={open ? '' : undefined}>
        <div inert={!open}>
          <p className={styles.likers}>
            Liked by {liked[track] ? 'you, ' : ''}
            {song.names}
          </p>
        </div>
      </div>
    </article>
  );
}
```

### skeleton-build/demo-pointer.tsx

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

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (anything that
 * plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (skeleton-build.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over a control. It is
 * decorative: what it does shows on the cards themselves. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### skeleton-build/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. It also tells whether the element is on screen
 * in a visible tab, and whether the reader prefers reduced motion.
 */
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 };
}
```

### skeleton-build/skeleton-build.module.css

```css
/* Skeleton build (skeleton-build.tsx, skeleton-reveal.tsx, cards.tsx): a pale stage with three
   cards in a row (stacked when narrow), each drawn first as a dotted blueprint and then swept in.

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

   Memory. The blueprint is one small SVG per card plus a CSS grid of dots; the sweep moves one
   element by a transform while the card's clip-path opens on the same clock. Nothing runs frame
   by frame in JavaScript, and every animation rests off screen (data-paused). */

.root {
  /* The stage, the cards on it, and the cards' quieter surfaces. */
  --stage: light-dark(#fafafa, #111112);
  --card: light-dark(#fff, #1c1c1e);
  --soft: light-dark(#f2f2f3, #2a2a2d);
  --chart: light-dark(#f7f7f8, #232326);
  --grid-line: light-dark(#e9e9eb, #313134);
  --rule: light-dark(#efefef, #2c2c2f);
  /* Words: main, secondary and faint. */
  --ink: light-dark(#141414, #f3f3f3);
  --muted: light-dark(#7b7b7e, #9c9ca1);
  --faint: light-dark(#a3a3a6, #75757a);
  /* The cards' edge and lift. */
  --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
  --lift: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.4));
  /* The blueprint: the grid's dots, the dotted lines, and the knots, handles and labels. */
  --sk-dot: light-dark(rgb(0 0 0 / 0.15), rgb(255 255 255 / 0.13));
  --sk-line: light-dark(rgb(0 0 0 / 0.4), rgb(255 255 255 / 0.36));
  --sk-ink: light-dark(#6c6c70, #a5a5aa);
  --sk-faint: light-dark(#a9a9ad, #6e6e73);
  /* The chart's series, the trace, the like. */
  --s0: light-dark(#2f6bff, #4a80ff);
  --s1: light-dark(#a52cf2, #b54ff6);
  --s2: light-dark(#f28a2e, #f59a46);
  --s3: light-dark(#2fbf3b, #3fcf4b);
  --ecg: light-dark(#e5484d, #ff5d62);
  --love: #ff4d6d;
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  /* The scan line starts fast and settles (an ease-out quad), as measured frame by frame. */
  --sweep-ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
  --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
  position: relative;
  container-type: inline-size;
  box-sizing: border-box;
  width: 100%;
  padding: 52px 24px 72px;
  border-radius: 20px;
  background: var(--stage);
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    sans-serif;
  font-size: 14px;
  line-height: 1.3;
  -webkit-font-smoothing: antialiased;
  overflow: clip;
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(h3, h4, p, dl, dd, ul) {
  margin: 0;
  padding: 0;
}
.root :where(button) {
  font: inherit;
  color: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.root :where(button):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.cards {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-start;
  gap: 56px 43px;
}

/* ---------------------------------------------------------------------------------------------
   The reveal: the blueprint under the card, the card (clipped while loading), the scan line. */

.reveal {
  position: relative;
  max-width: 100%;
}
.wire {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transition: opacity 280ms ease;
}
.grid {
  position: absolute;
  inset: calc(-1 * var(--margin));
  /* Dots every 15px from the card's corner (GRID in skeleton-reveal.tsx): the layer starts
     var(--margin) (44px) out, so its tiles are shifted 44 mod 15 = 14px. */
  background: radial-gradient(circle at 0.5px 0.5px, var(--sk-dot) 1px, transparent 1.5px) 13.5px
    13.5px / 15px 15px;
  mask-image:
    linear-gradient(
      to right,
      transparent,
      #000 var(--margin),
      #000 calc(100% - var(--margin)),
      transparent
    ),
    linear-gradient(
      to bottom,
      transparent,
      #000 var(--margin),
      #000 calc(100% - var(--margin)),
      transparent
    );
  mask-composite: intersect;
}
.reveal .wireDrawing {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.wireDrawing text {
  font-family: var(--mono);
  font-size: 7.3px;
  letter-spacing: 0.02em;
  fill: var(--sk-faint);
}
.wireDrawing .sizeLabel {
  font-size: 9.2px;
  fill: var(--sk-faint);
}
.dots,
.outline,
.guide {
  fill: none;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
.dots {
  stroke: var(--sk-line);
  stroke-width: 1.4;
  stroke-dasharray: 0 3.2;
}
.outline {
  stroke: var(--sk-line);
  stroke-width: 1.7;
  stroke-dasharray: 0 2.6;
}
.guide {
  stroke: var(--sk-dot);
  stroke-width: 1.4;
  stroke-dasharray: 0 3.4;
}
.handle {
  fill: var(--stage);
  stroke: var(--sk-ink);
  stroke-width: 1;
}
.knot {
  fill: var(--sk-ink);
}
.part {
  animation: partIn 260ms ease-out both;
  animation-delay: calc(var(--i) * var(--step));
}
.flicker {
  fill: var(--sk-ink);
  opacity: 0;
  animation: flicker 2.4s steps(1, end) infinite;
}
.bracket path {
  fill: none;
  stroke: var(--sk-ink);
  stroke-width: 1.3;
}
.wireDrawing .bracket text {
  fill: var(--sk-ink);
  font-size: 8px;
}
.wireDrawing .bracket .bracketAt {
  fill: var(--sk-faint);
  font-size: 7.3px;
}
.reveal[data-paused] .wire *,
.reveal[data-paused] .trace path {
  animation-play-state: paused;
}

.content {
  position: relative;
}
.reveal[data-phase='building'] .content {
  clip-path: inset(-48px -48px 100% -48px);
  opacity: 0.85;
  filter: blur(2px);
}
.reveal[data-phase='sweeping'] .content {
  clip-path: inset(-48px -48px 0 -48px);
  transition:
    clip-path var(--duration) var(--sweep-ease) var(--delay),
    opacity var(--duration) ease-out var(--delay),
    filter var(--duration) ease-out var(--delay);
}
.reveal[data-phase='hiding'] .content {
  opacity: 0;
  filter: blur(8px);
  transform: scale(0.985);
  transition:
    opacity 280ms ease var(--hide-delay),
    filter 280ms ease var(--hide-delay),
    transform 280ms ease var(--hide-delay);
}
.reveal[data-phase='sweeping'] .wire {
  opacity: 0;
  transition-delay: calc(var(--delay) + var(--duration) - 60ms);
}
.reveal[data-phase='shown'] .wire {
  opacity: 0;
  visibility: hidden;
}

.sweep {
  position: absolute;
  top: 0;
  left: -18px;
  right: -18px;
  height: 100%;
  pointer-events: none;
  opacity: 0;
  transform: translateY(-100%);
}
.reveal[data-phase='sweeping'] .sweep {
  opacity: 1;
  transform: translateY(0);
  transition:
    transform var(--duration) var(--sweep-ease) var(--delay),
    opacity 100ms linear var(--delay);
}
.reveal[data-phase='shown'] .sweep {
  transform: translateY(0);
  transition: opacity 180ms ease;
}
/* Just above the line the card is still soft and pale: the rows it has just passed are blurred
   through this band (sharp at its top, most blurred at the line), as if still coming into focus. */
.sweepVeil {
  position: absolute;
  left: 18px;
  right: 18px;
  bottom: 0;
  height: 72px;
  background: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--stage) 60%, transparent)
  );
  backdrop-filter: blur(5px);
  mask-image: linear-gradient(to bottom, transparent, #000 70%);
}
.sweepLine {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 2px;
  background: radial-gradient(circle, var(--sk-ink) 0.85px, transparent 1.25px) 0 50% / 4px 2px
    repeat-x;
}
.sweepLine::before,
.sweepLine::after {
  content: '';
  position: absolute;
  top: -2px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--sk-ink);
}
.sweepLine::before {
  left: 15px;
}
.sweepLine::after {
  right: 15px;
}

@keyframes partIn {
  from {
    opacity: 0;
  }
}
@keyframes flicker {
  0% {
    opacity: 0;
  }
  8% {
    opacity: 0.85;
  }
  22% {
    opacity: 0;
  }
}

/* ---------------------------------------------------------------------------------------------
   The cards. */

.card {
  position: relative;
  border-radius: 24px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--edge),
    0 2px 4px var(--lift),
    0 14px 36px var(--lift);
  text-align: left;
}
.root .icon {
  width: 16px;
  height: 16px;
  fill: currentColor;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.root .icon path {
  fill: none;
}
.icon circle {
  stroke: none;
}
.muted {
  color: var(--muted);
}
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.cardTitle {
  font-size: 28px;
  line-height: 32px;
  font-weight: 500;
  letter-spacing: -0.01em;
}
.subtitle {
  margin-top: 4px;
  color: var(--muted);
  font-size: 13px;
  line-height: 18px;
}
.swap {
  animation: swapIn 380ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes swapIn {
  from {
    opacity: 0;
    filter: blur(6px);
  }
}

/* Heart rate. */
.heart {
  width: min(332px, 100cqi - 32px);
  padding: 16px 16px 25px;
}
.heartTop {
  display: grid;
  grid-template-columns: 30px 1fr 30px;
  align-items: center;
}
.heartTitle {
  justify-self: center;
  color: var(--muted);
  font-size: 13px;
  font-weight: 400;
}
.roundButton {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--edge),
    0 1px 3px var(--lift);
}
.roundButton:hover {
  background: var(--soft);
}
.roundButton .icon {
  width: 13px;
  height: 13px;
}
.reading {
  display: flex;
  justify-content: center;
  align-items: baseline;
  gap: 10px;
  margin-top: 28px;
}
.readingValue {
  font-size: 60px;
  line-height: 56px;
  font-weight: 400;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
.readingUnit {
  color: var(--muted);
  font-size: 15px;
}
.digits {
  display: inline-flex;
}
.digit {
  display: inline-block;
  animation: digitIn 360ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes digitIn {
  from {
    opacity: 0;
    filter: blur(6px);
    transform: translateY(30%);
  }
}
.trace {
  height: 96px;
  margin-top: 11px;
  border-radius: 12px;
  background-color: var(--chart);
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size:
    100% 26px,
    30px 100%;
  background-position:
    0 9px,
    14px 0;
  overflow: hidden;
}
.root .trace svg {
  display: block;
  width: 100%;
  height: 100%;
}
.trace path {
  fill: none;
  stroke: var(--ecg);
  stroke-width: 1.8;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
  stroke-dasharray: 30 100;
  stroke-dashoffset: -44;
}
.trace[data-live] path {
  animation: trace 2.8s linear infinite;
}
@keyframes trace {
  from {
    stroke-dashoffset: 30;
  }
  to {
    stroke-dashoffset: -100;
  }
}
.statsTitle {
  margin: 19px 0 0 8px;
  font-size: 16px;
  line-height: 20px;
  font-weight: 500;
}
.stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  margin: 7px 0 0 8px;
}
.stats dt {
  color: var(--muted);
  font-size: 12px;
  line-height: 15px;
}
.stats dd {
  margin-top: 3px;
  font-size: 13.5px;
  line-height: 18px;
}

/* Token spend. */
.tokens {
  width: min(320px, 100cqi - 32px);
  padding: 25px 24px 24px;
}
.segmented {
  display: inline-flex;
  margin-top: 11px;
  padding: 2px;
  border-radius: 999px;
  background: var(--soft);
}
.segmented button {
  height: 27px;
  padding: 0 11px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--muted);
  font-size: 12.5px;
  transition:
    background-color 200ms ease,
    color 200ms ease,
    box-shadow 200ms ease;
}
.segmented button:hover {
  color: var(--ink);
}
.segmented button[aria-pressed='true'] {
  background: var(--card);
  color: var(--ink);
  font-weight: 500;
  box-shadow:
    0 0 0 0.5px var(--edge),
    0 1px 3px var(--lift);
}
.rule {
  margin: 24px 0 0;
  border: 0;
  border-top: 1px solid var(--rule);
}
.total {
  margin-top: 16px;
  font-size: 17px;
  line-height: 22px;
}
.total .muted {
  margin-left: 4px;
  font-size: 13px;
}
.bars {
  display: flex;
  gap: 8px;
  margin-top: 15px;
}
.bars[data-count='12'] {
  gap: 4px;
}
.barColumn {
  flex: 1;
  display: grid;
  grid-template-rows: 127px auto;
  min-width: 0;
}
.bar {
  align-self: end;
  display: flex;
  flex-direction: column-reverse;
  gap: 2px;
  transition: height 480ms cubic-bezier(0.3, 0.7, 0.2, 1);
}
.bar span {
  flex-basis: 0;
  min-height: 3px;
  border-radius: 3px;
  transition: flex-grow 480ms cubic-bezier(0.3, 0.7, 0.2, 1);
}
.root [data-series='0'] {
  background: var(--s0);
}
.root [data-series='1'] {
  background: var(--s1);
}
.root [data-series='2'] {
  background: var(--s2);
}
.root [data-series='3'] {
  background: var(--s3);
}
.barLabel {
  margin-top: 8px;
  color: var(--muted);
  font-size: 12.5px;
  text-align: center;
  white-space: nowrap;
}
.bars[data-count='12'] .barLabel {
  font-size: 11px;
}
.legend {
  display: grid;
  gap: 8px;
  margin-top: 20px;
  list-style: none;
}
.legend li {
  display: grid;
  grid-template-columns: 8px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 0 9px 0 8px;
  font-size: 14px;
  line-height: 20px;
}
.swatch {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.legendValue {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  animation: swapIn 380ms ease both;
}
.details {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  height: 34px;
  margin-top: 21px;
  padding: 0 12px 0 12px;
  border: 0;
  border-radius: 10px;
  background: var(--soft);
  font-size: 13.5px;
  font-weight: 500;
}
.details .icon,
.more .icon {
  width: 14px;
  height: 14px;
  transition: transform 260ms ease;
}
.details[aria-expanded='true'] .icon,
.more[aria-expanded='true'] .icon {
  transform: rotate(180deg);
}
.detailsPanel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms cubic-bezier(0.3, 0.7, 0.2, 1);
}
.detailsPanel[data-open] {
  grid-template-rows: 1fr;
}
.detailsPanel > * {
  min-height: 0;
  overflow: hidden;
}
.detailsPanel dl {
  display: grid;
  gap: 6px;
  padding: 12px 16px 2px;
  font-size: 13px;
}
.detailsPanel dl div {
  display: flex;
  justify-content: space-between;
}
.detailsPanel dt {
  color: var(--muted);
}

/* Player. */
.player {
  width: min(360px, 100cqi - 32px);
  padding: 24px 24px 14px;
  background:
    radial-gradient(85% 50% at 88% 0%, var(--tint), transparent 75%),
    radial-gradient(60% 40% at 10% 0%, var(--tint-2), transparent 70%), var(--card);
}
.player[data-art='dawn'] {
  --tint: light-dark(#fde9f1, #3a2430);
  --tint-2: light-dark(#f4efff, #262236);
}
.player[data-art='dusk'] {
  --tint: light-dark(#efe4ff, #2e2442);
  --tint-2: light-dark(#e4f3ff, #1d2a3a);
}
.art {
  position: relative;
  width: 112px;
  height: 112px;
  border-radius: 12px;
  overflow: hidden;
  background: var(--soft);
}
.artImage {
  position: absolute;
  inset: 0;
  animation: swapIn 420ms ease both;
}
.artImage[data-art='dawn'] {
  background:
    radial-gradient(34% 22% at 52% 70%, #fff4c9 0 55%, transparent 100%),
    radial-gradient(48% 30% at 46% 52%, rgb(255 255 255 / 0.85) 0 45%, transparent 100%),
    radial-gradient(70% 45% at 70% 25%, #ffe0ec, transparent 70%),
    linear-gradient(170deg, #fbd5e5 0%, #f1dcf3 38%, #bdbaf6 72%, #8f8ce9 100%);
}
.artImage[data-art='dusk'] {
  background:
    radial-gradient(90% 55% at 15% 95%, #19c6ff 0 30%, transparent 70%),
    radial-gradient(80% 70% at 95% 5%, #ff5fb1 0 25%, transparent 65%),
    radial-gradient(120% 40% at 60% 60%, rgb(255 255 255 / 0.35), transparent 60%),
    linear-gradient(150deg, #ff6ab8 0%, #7b2ff7 50%, #2c5cff 100%);
}
.song {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 8px;
  margin-top: 17px;
}
.song .subtitle {
  font-size: 13.5px;
}
.like {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
}
.like:hover {
  background: color-mix(in srgb, var(--soft) 70%, transparent);
}
.root .glyph {
  width: 18px;
  height: 18px;
  fill: currentColor;
}
.like .glyph {
  width: 21px;
  height: 21px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  transition:
    fill 200ms ease,
    stroke 200ms ease;
}
.like[aria-pressed='true'] .glyph {
  fill: var(--love);
  stroke: var(--love);
  animation: pop 360ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
@keyframes pop {
  40% {
    transform: scale(1.25);
  }
}
.progress {
  margin-top: 25px;
}
.track {
  height: 3px;
  border-radius: 2px;
  background: var(--soft);
  overflow: hidden;
}
.track span {
  display: block;
  height: 100%;
  background: var(--ink);
  transform-origin: left;
  transition: transform 1s linear;
}
.times {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
  color: var(--muted);
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
}
.controls {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 32px;
  margin-top: 24px;
}
.skip {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
}
.skip:hover {
  background: var(--soft);
}
.play {
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  color: var(--card);
  box-shadow: 0 4px 12px var(--lift);
  transition: transform 160ms ease;
}
.play:active,
.skip:active {
  transform: scale(0.94);
}
.play .glyph {
  width: 22px;
  height: 22px;
}
.skip .glyph {
  width: 26px;
  height: 26px;
}
.footer {
  display: flex;
  align-items: center;
  gap: 24px;
  margin-top: 24px;
  padding-top: 14px;
  border-top: 1px solid var(--rule);
}
.avatars {
  display: flex;
}
.avatars span {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  margin-right: -6px;
  border: 2px solid var(--card);
  border-radius: 50%;
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.02em;
}
.avatars span[data-tone='0'] {
  background: light-dark(#e6f4ec, #1f3328);
  color: light-dark(#2f7a50, #7fd3a2);
}
.avatars span[data-tone='1'] {
  background: light-dark(#efe8fb, #2c2540);
  color: light-dark(#6b48b8, #b9a2f0);
}
.avatars span[data-tone='2'] {
  background: light-dark(#e6eef9, #1f2a3b);
  color: light-dark(#3a5f99, #93b4ea);
}
.likes {
  font-size: 14px;
}
.more {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin-left: auto;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
}
.more:hover {
  background: var(--soft);
}
.likers {
  padding: 10px 0 4px;
  color: var(--muted);
  font-size: 13px;
}

/* ---------------------------------------------------------------------------------------------
   Replay, and the demo pointer. */

.replay {
  position: absolute;
  right: 16px;
  bottom: 16px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 30px;
  padding: 0 12px 0 10px;
  border: 1px solid var(--edge);
  border-radius: 999px;
  background: var(--card);
  color: var(--muted);
  font-size: 12.5px;
  box-shadow: 0 1px 2px var(--lift);
}
.replay:hover:not(:disabled) {
  color: var(--ink);
}
.replay:disabled {
  opacity: 0.5;
  cursor: default;
}
.root .replay svg {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.pointerLayer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

/* Three cards take 1098px in a row (the container's width is inside the padding): a narrower
   stage draws them smaller, until it is too narrow for that and stacks them at their own size. */
@container (min-width: 880px) and (max-width: 1097.98px) {
  .cards {
    zoom: 0.8;
  }
}
@container (min-width: 728px) and (max-width: 879.98px) {
  .cards {
    zoom: 0.66;
  }
}
@container (max-width: 520px) {
  .root {
    padding: 48px 16px 64px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .reveal .sweep {
    display: none;
  }
  .reveal[data-phase='building'] .content {
    clip-path: none;
    opacity: 0;
    filter: none;
  }
  .reveal[data-phase='sweeping'] .content {
    clip-path: none;
    filter: none;
    transition: opacity 240ms ease var(--delay);
  }
  .reveal[data-phase='hiding'] .content {
    filter: none;
    transform: none;
  }
  .part,
  .digit,
  .swap,
  .legendValue,
  .artImage {
    animation: none;
  }
  .flicker,
  .trace[data-live] path {
    animation: none;
  }
  .bar,
  .bar span,
  .track span,
  .detailsPanel {
    transition: none;
  }
}
```