# Card reveal

> A masked card number whose hidden digits roll into place one after another; copy it, and it hides itself again.

A card number with its middle digits hidden, and an eye button. Reveal it and each hidden digit rolls up into place a beat after the one before, landing faint and inking in, while the eye turns into a copy button. Copying turns it green with a check for a moment, and a ring round the button runs down the time until the number hides again.

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

Category: Data.

## Anatomy

1. **Stage**: The surface the field sits on, with a dot grid. Its `data-state` (hidden, shown, copied) drives every transition.
2. **Caption**: The `label` prop: the line above the field, and the field’s accessible name.
3. **Field**: A raised pill holding the number and the button. Under 400 px of room it gets shorter and tighter.
4. **Number**: Groups of four in tabular figures. The first four and last four always show; it goes from grey to ink when revealed.
5. **Hidden digit**: A slot one line tall over a reel of a dot and the digit. Revealing rolls it up on a bouncy spring, 42 ms after the one before.
6. **Action button**: An eye while hidden, a copy button once shown, green with a check for 1.6 s after a copy. Its label says what it will do.
7. **Countdown ring**: Runs out round the button over `hideAfter`, then the number hides. Every press starts it again.

## When to use

- A stored card number, account number or IBAN that is masked by default and shown only on request.
- Settings, wallets and billing pages where the reader sometimes needs the whole number to copy it somewhere else.
- Values that should hide again on their own, so a number left open on a shared screen doesn’t stay open.
- One number at a time with a single button that shows it first and copies it once shown.

## When not to use

- Entering a card number. Use a real input with formatting and validation.
- Passwords, API keys or one-time codes: the digits-and-groups layout is made for card-like numbers. Use a secret field.
- Numbers that are safe to show, like an order number. Show them plainly with a copy button.
- A list of many masked numbers: one reveal each, each with its own countdown, is noisy. Show the last four and open a detail view.

## Installation

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

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

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

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

## Usage

```tsx
import { CardReveal } from './card-reveal';

export function CardDetails() {
  return (
    <CardReveal
      label="Card number"
      number="4921735018463087"
      hideAfter={8000}
      autoplay={false}
    />
  );
}
```

## States

- **Hidden**: The middle eight digits are dots and the number is grey. The eye button shows it.
- **Shown**: Every digit in ink, a copy button, and the ring running down. Here the countdown is set to an hour so it holds.
- **Copied**: The button turns green with a check, and the ring with it. Normally it goes back to copy after 1.6 s.
- **Compact**: With 400 px of room or less the field is 60 px tall, the type 17 px and the button 40 px.
- **Dark**: Set by `colorScheme="dark"`, or inherited from the page’s color-scheme: every colour is a light-dark() token.

## How it works

- Each hidden digit sits in a slot one line tall, with soft masked edges, on a reel of two glyphs: the dot and the digit.
- Revealing slides each reel up by a line on a bouncy spring, 42 ms after the one before. The glyphs cross-fade with a touch of blur, and each digit lands faint and inks in a beat later. Hiding plays it back down.
- It is all CSS transitions keyed off one data attribute on the root (hidden, shown, copied), so nothing runs at rest, and an interruption reverses from wherever each digit is.
- The button cross-fades its icons with a blur: eye, copy, check. A ring round it, an SVG stroke whose dash runs out, shows the time left and restarts on every press.
- Copy writes the digits to the clipboard, and only turns green once that has worked. The demo shows the copied look without touching the clipboard.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the demo cursor reads the stage’s pointer tokens, and its press ripple turns light at night.

## API reference

- **number** (`string`, default `'4921735018463087'`): The digits (spaces are ignored). The first four and the last four stay visible. The default is made up and fails the card checksum, so it cannot be a real card.
- **label** (`string`, default `'Card number'`): The caption, and the name used in the button’s label and the announcements.
- **hideAfter** (`number`, default `6000`): How long the number stays shown after the last press, in ms.
- **defaultState** (`'hidden' | 'shown' | 'copied'`): Opens in this state and turns the demo off; shown or copied starts the countdown, and copied keeps its check until a press or the hide.
- **onStateChange** (`(state: 'hidden' | 'shown' | 'copied') => void`): Called on each change: shown, copied, back to shown after the check, hidden.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo cursor reveals the number and “copies” it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- One button that says what it does: “Show card number”, then “Copy card number”, then “Card number copied”. It keeps its place, so focus never jumps, and Escape hides the number.
- Screen readers get the number digit by digit, or “hidden, ending in 3 0 8 7”. The decorative reels are hidden from them.
- Reveal, copy, hide and a failed copy are announced politely, the reveal with the number of seconds it stays shown.
- Under reduced motion the digits switch at once, the ring is not drawn and the demo does not play; the number still hides itself.

## Source

### card-reveal/card-reveal.tsx

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

'use client';

import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';
import { DemoCursor, playDemo } from './demo-cursor';
import { spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './card-reveal.module.css';

/*
 * Card reveal: a card number with its middle digits hidden ("4921 •••• •••• 3087") and an eye
 * button. Revealing rolls each hidden digit into place, one after another, and the eye turns into
 * a copy button; copying turns it green with a check for a moment. A ring round the button runs
 * down the time left, and then the number hides itself again.
 *
 * - Each hidden digit sits in a slot one line tall, on a reel of two glyphs, the dot and the
 *   digit. Revealing slides the reel up by a line on a bouncy spring, a little later for each
 *   slot; the glyphs cross-fade with a touch of blur, and each digit lands faint and inks in.
 *   Hiding plays it back down.
 * - All of it is CSS transitions keyed off one data attribute, so nothing runs at rest.
 * - The button keeps its place and its label says what it does: show, copy, copied. Escape hides
 *   the number. Screen readers get the number digit by digit, and the changes announced.
 * - While on screen and untouched, a demo cursor reveals the number and copies it (the demo
 *   never touches the clipboard).
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type CardRevealState = 'hidden' | 'shown' | 'copied';
type State = CardRevealState;

/** The roll: a spring with a bounce, so each digit lands with a little overshoot. */
const ROLL = spring(0.32, 420);
/** How long the check stays after a copy, in ms. */
const COPIED_FOR = 1600;

export function CardReveal({
  number = '4921735018463087',
  label = 'Card number',
  hideAfter = 6000,
  autoplay = true,
  colorScheme,
  defaultState,
  onStateChange,
  className = '',
}: {
  /** The digits, without spaces: 16 of them shows as four groups of four. */
  number?: string;
  /** What the number is: the caption above it, and its accessible name. */
  label?: string;
  /** How long the number stays shown after the last press, in ms. */
  hideAfter?: number;
  /** While on screen and untouched, a demo cursor reveals the number and copies it. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens in this state, and turns the demo off. Shown or copied opens with the countdown
   * running (hideAfter); copied keeps its check until a press or the hide.
   */
  defaultState?: CardRevealState;
  /** Called on each change: shown, copied, back to shown after the check, hidden. */
  onStateChange?: (state: CardRevealState) => void;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [state, setState] = useState<State>(defaultState ?? 'hidden');
  /** Bumped on every press while shown: it restarts the countdown ring. */
  const [round, setRound] = useState(0);
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const labelId = useId();
  const button = useRef<HTMLButtonElement>(null);
  const cursor = useRef<HTMLSpanElement>(null);
  const hideTimer = useRef(0);
  const copiedTimer = useRef(0);
  /** Whether the reader showed the number, so its hiding is announced too. */
  const shownByReader = useRef(false);

  const digits = number.replace(/\D/g, '');
  // In groups of four; the first four digits and the last four stay visible.
  const cells = [...digits].map((digit, at) => ({
    digit,
    at,
    masked: at >= 4 && at < digits.length - 4,
  }));
  const groups = Array.from({ length: Math.ceil(cells.length / 4) }, (_, g) =>
    cells.slice(g * 4, g * 4 + 4),
  );
  const shown = state !== 'hidden';

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const hide = () => {
    window.clearTimeout(hideTimer.current);
    window.clearTimeout(copiedTimer.current);
    setState('hidden');
    if (shownByReader.current) announce(`${label} hidden.`);
    shownByReader.current = false;
  };

  /** (Re)starts the time the number stays shown. */
  const keepShown = () => {
    window.clearTimeout(hideTimer.current);
    hideTimer.current = window.setTimeout(hide, hideAfter);
    setRound((now) => now + 1);
  };

  const reveal = (byReader: boolean) => {
    shownByReader.current = byReader;
    setState('shown');
    keepShown();
    if (byReader) {
      announce(`${label} shown. It hides again in ${Math.round(hideAfter / 1000)} seconds.`);
    }
  };

  const copied = () => {
    setState('copied');
    keepShown();
    window.clearTimeout(copiedTimer.current);
    copiedTimer.current = window.setTimeout(
      () => setState((now) => (now === 'copied' ? 'shown' : now)),
      COPIED_FOR,
    );
  };

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(digits);
    } catch {
      announce('Copying is not available here.');
      return;
    }
    copied();
    announce(`${label} copied.`);
  };

  // Opened shown or copied: the countdown starts at once.
  const openShown = useEffectEvent(() => keepShown());
  useEffect(() => {
    if (defaultState && defaultState !== 'hidden') openShown();
  }, [defaultState]);

  // Reports each change after it is drawn, not the state it opened in.
  const report = useEffectEvent((now: State) => onStateChange?.(now));
  const reported = useRef(state);
  useEffect(() => {
    if (reported.current === state) return;
    reported.current = state;
    report(state);
  }, [state]);

  useEffect(
    () => () => {
      window.clearTimeout(hideTimer.current);
      window.clearTimeout(copiedTimer.current);
    },
    [],
  );

  // The demo: the cursor reveals the number, copies it (only the look of it), and lets it hide.
  const demoAct = useEffectEvent((act: 'reveal' | 'copy') =>
    act === 'reveal' ? reveal(false) : copied(),
  );
  const isHidden = useEffectEvent(() => state === 'hidden');
  const demo = autoplay && defaultState === undefined && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const arrow = cursor.current;
    const target = button.current;
    if (!demo || !stage || !arrow || !target) return;
    return playDemo(stage, arrow, async ({ wait, moveTo, press, leave }) => {
      for (let turn = 0; ; turn += 1) {
        await wait(turn ? 900 : 1100);
        // Shown by a reader: wait until it hides itself.
        while (!isHidden()) await wait(300);
        await wait(turn ? 900 : 0);
        await moveTo(target);
        await press(target, () => demoAct('reveal'));
        await wait(1500);
        await press(target, () => demoAct('copy'));
        await wait(700);
        await leave();
      }
    });
  }, [demo, root]);

  const actionLabel =
    state === 'hidden' ? `Show ${label.toLowerCase()}` : `Copy ${label.toLowerCase()}`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="root"
      data-state={state}
      data-color-scheme={colorScheme}
      style={
        {
          '--roll': ROLL.easing,
          '--roll-ms': `${ROLL.duration}ms`,
          '--hide-after': `${hideAfter}ms`,
        } as CSSProperties
      }
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && shown) {
          event.preventDefault();
          shownByReader.current = true;
          hide();
        }
      }}
    >
      <div className={styles.body}>
        <span id={labelId} className={styles.caption} data-part="caption">
          {label}
        </span>
        <div className={styles.field} role="group" aria-labelledby={labelId} data-part="field">
          <span className={styles.number} aria-hidden="true" data-part="number">
            {groups.map((group, g) => (
              <span key={g} className={styles.group}>
                {group.map(({ digit, at, masked }) =>
                  masked ? (
                    // A reel of two glyphs in a slot one line tall: the dot, then the digit.
                    <span
                      key={at}
                      className={styles.slot}
                      data-part="slot"
                      style={{ '--i': at - 4 } as CSSProperties}
                    >
                      <span className={styles.reel}>
                        <span className={styles.dot}>•</span>
                        <span className={styles.digit}>{digit}</span>
                      </span>
                    </span>
                  ) : (
                    <span key={at}>{digit}</span>
                  ),
                )}
              </span>
            ))}
          </span>
          <span className={styles.hidden}>
            {shown ? [...digits].join(' ') : `hidden, ending in ${[...digits.slice(-4)].join(' ')}`}
          </span>
          <button
            ref={button}
            type="button"
            className={styles.action}
            data-part="action"
            aria-label={state === 'copied' ? `${label} copied` : actionLabel}
            onClick={() => (shown ? copy() : reveal(true))}
          >
            <svg className={styles.eye} viewBox="0 0 20 20" aria-hidden="true">
              <path d="M10 4.6c-3.9 0-6.6 3.3-7.6 4.8a1.1 1.1 0 0 0 0 1.2c1 1.5 3.7 4.8 7.6 4.8s6.6-3.3 7.6-4.8a1.1 1.1 0 0 0 0-1.2c-1-1.5-3.7-4.8-7.6-4.8Z" />
              <circle cx="10" cy="10" r="2.6" />
            </svg>
            <svg className={styles.copy} viewBox="0 0 20 20" aria-hidden="true">
              <rect x="7.25" y="7.25" width="9.5" height="9.5" rx="2.25" />
              <path d="M12.75 7.25V5.5a2.25 2.25 0 0 0-2.25-2.25h-5a2.25 2.25 0 0 0-2.25 2.25v5a2.25 2.25 0 0 0 2.25 2.25h1.75" />
            </svg>
            <svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true">
              <path d="m5.2 10.4 3.2 3.2 6.6-7" />
            </svg>
            {/* The time left, running down round the button while the number is shown. */}
            {shown && (
              <svg
                key={round}
                className={styles.ring}
                data-part="ring"
                viewBox="0 0 54 54"
                aria-hidden="true"
              >
                <rect x="1" y="1" width="52" height="52" rx="18" pathLength="100" />
              </svg>
            )}
          </button>
        </div>
      </div>
      <DemoCursor ref={cursor} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### card-reveal/card-reveal.module.css

```css
/* Card reveal (card-reveal.tsx): a stage with the card number in a field raised on it. The root's
   data-state (hidden, shown or copied) drives every transition.

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

.root {
  /* The stage, its dot grid and its edge; the field raised on it, its hairline and its lift. */
  --surface: #e4e4e7;
  --grid: rgb(0 0 0 / 0.055);
  --line: rgb(0 0 0 / 0.06);
  --surface-2: #fff;
  --edge: rgb(0 0 0 / 0.05);
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow: rgb(0 0 0 / 0.22);
  /* The words: the caption, the number shown, and the number while partly hidden. */
  --caption: #636366;
  --ink: #1d1d1f;
  --ink-muted: #6e6e73;
  /* The hidden digits' dots, and the faint colour a digit lands in before it takes the ink. */
  --faint: #aeaeb2;
  --pale: #c7c7cc;
  /* The button: the eye's (tinted, its glyph in the accent), the copy's, then the check's. */
  --accent: #efeefd;
  --accent-hover: #e6e5fc;
  --accent-ink: #5856d6;
  --control: #f2f2f4;
  --control-hover: #eaeaed;
  --success: #e3f6e8;
  --success-edge: #a6e0b5;
  --success-ink: #1f9d48;
  /* The ring of time left round the button, and the same once copied. */
  --ring: #c9c9ce;
  --success-ring: #8fd6a2;
  /* The same on both schemes: the focus ring, and the demo's cursor, which looks like the
     system's; its press ripple is light on dark. */
  --focus: #0a84ff;
  --pointer: #1d1d1f;
  --pointer-edge: #fff;
  --pointer-ripple: rgb(0 0 0 / 0.14);
  --stagger: 42ms;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--grid) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font:
    400 15px/1.3 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e4e4e7, #111113);
    --grid: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.05));
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --surface-2: light-dark(#fff, #232326);
    --edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.65));
    --caption: light-dark(#636366, #98989d);
    --ink: light-dark(#1d1d1f, #f2f2f7);
    --ink-muted: light-dark(#6e6e73, #98989d);
    --faint: light-dark(#aeaeb2, #636368);
    --pale: light-dark(#c7c7cc, #48484c);
    --accent: light-dark(#efeefd, #2b2a4e);
    --accent-hover: light-dark(#e6e5fc, #35345c);
    --accent-ink: light-dark(#5856d6, #9d9bff);
    --control: light-dark(#f2f2f4, #2e2e32);
    --control-hover: light-dark(#eaeaed, #38383d);
    --success: light-dark(#e3f6e8, #16301f);
    --success-edge: light-dark(#a6e0b5, #255f3a);
    --success-ink: light-dark(#1f9d48, #30d158);
    --ring: light-dark(#c9c9ce, #505055);
    --success-ring: light-dark(#8fd6a2, #2e7d4a);
    --pointer-ripple: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.2));
  }
}
/* 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;
}

.body {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  min-height: 420px;
  padding: 48px 16px;
}
.caption {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--caption);
}

.field {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  max-width: 420px;
  height: 72px;
  padding: 0 13px 0 24px;
  border-radius: 22px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--edge),
    0 1px 2px var(--shadow-near),
    0 12px 30px -18px var(--shadow);
}

/* The number: grey while partly hidden, ink once shown. */
.number {
  display: flex;
  flex: 1;
  gap: 0.42em;
  min-width: 0;
  font-size: 21px;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-muted);
  white-space: nowrap;
  user-select: none;
  transition: color 0.45s ease;
}
.root:not([data-state='hidden']) .number {
  color: var(--ink);
}
.group {
  display: inline-flex;
  align-items: center;
}

/* A hidden digit: a slot one line tall, its edges soft, over a reel of two glyphs. */
.slot {
  display: inline-block;
  height: 1.3em;
  overflow: hidden;
  overflow: clip;
  mask-image: linear-gradient(transparent, #000 24%, #000 76%, transparent);
}
.reel {
  display: flex;
  flex-direction: column;
  transition: transform var(--roll-ms) var(--roll) calc(var(--i) * var(--stagger));
}
.root:not([data-state='hidden']) .reel {
  transform: translateY(-50%);
}
.dot,
.digit {
  display: block;
  height: 1.3em;
  text-align: center;
  transition:
    opacity 0.28s ease calc(var(--i) * var(--stagger)),
    filter 0.28s ease calc(var(--i) * var(--stagger)),
    color 0.5s ease calc(var(--i) * var(--stagger) + 160ms);
}
.dot {
  color: var(--faint);
  transform: scale(1.3);
}
/* A digit lands faint and takes the ink a beat later. */
.digit {
  color: var(--pale);
  opacity: 0;
  filter: blur(2px);
}
.root:not([data-state='hidden']) .dot {
  opacity: 0;
  filter: blur(2px);
}
.root:not([data-state='hidden']) .digit {
  color: var(--ink);
  opacity: 1;
  filter: none;
}

/* The button: an eye, then a copy button, then green with a check for a moment. */
.action {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 46px;
  height: 46px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 15px;
  background: var(--accent);
  color: var(--accent-ink);
  cursor: pointer;
  transition:
    background-color 0.3s ease,
    color 0.3s ease,
    box-shadow 0.3s ease,
    transform 0.15s ease;
}
.root[data-state='shown'] .action {
  background: var(--control);
  color: var(--ink);
}
.root[data-state='copied'] .action {
  background: var(--success);
  color: var(--success-ink);
  box-shadow: inset 0 0 0 1px var(--success-edge);
}
@media (hover: hover) {
  .root[data-state='hidden'] .action:hover {
    background: var(--accent-hover);
  }
  .root[data-state='shown'] .action:hover {
    background: var(--control-hover);
  }
}
.action:is(:active, [data-pressed]) {
  transform: scale(0.92);
}
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.action > svg:not(.ring) {
  grid-area: 1 / 1;
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition:
    opacity 0.26s ease,
    filter 0.26s ease,
    transform var(--roll-ms) var(--roll);
}
.eye circle {
  fill: currentColor;
  stroke: none;
}
.check {
  stroke-width: 2.2;
}
.root:not([data-state='hidden']) .eye,
.root:not([data-state='shown']) .copy,
.root:not([data-state='copied']) .check {
  opacity: 0;
  filter: blur(3px);
  transform: scale(0.5);
}

/* The time left: a ring round the button that runs out as the number hides. */
.ring {
  position: absolute;
  inset: -4px;
  width: calc(100% + 8px);
  height: calc(100% + 8px);
  overflow: visible;
  pointer-events: none;
}
.ring rect {
  fill: none;
  stroke: var(--ring);
  stroke-width: 1.5;
  stroke-dasharray: 100;
  animation: run-out var(--hide-after) linear forwards;
  transition: stroke 0.3s ease;
}
.root[data-state='copied'] .ring rect {
  stroke: var(--success-ring);
}
@keyframes run-out {
  to {
    stroke-dashoffset: 100;
  }
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@container (max-width: 400px) {
  .body {
    min-height: 340px;
  }
  .field {
    gap: 8px;
    height: 60px;
    padding: 0 9px 0 16px;
    border-radius: 18px;
  }
  .number {
    font-size: 17px;
    letter-spacing: 0.02em;
  }
  .action {
    width: 40px;
    height: 40px;
    border-radius: 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .number,
  .reel,
  .dot,
  .digit,
  .action,
  .action > svg:not(.ring) {
    transition: none;
  }
  .ring {
    display: none;
  }
}
```

### card-reveal/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once and handed to CSS
 * transitions as an easing.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}
```

### card-reveal/demo-cursor.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './demo-cursor.module.css';

/*
 * The demo's cursor. While a demo plays on its own, an arrow glides to the controls and presses
 * them, so the demo reads like someone using the component. Each glide is a CSS transition, one
 * per axis with a different easing, which bends the path into a gentle arc: nothing runs between
 * moves.
 */

export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">
      <span className={styles.vertical}>
        <span className={styles.ripple} />
        <svg className={styles.arrow} viewBox="0 0 20 20">
          <path d="M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z" />
        </svg>
      </span>
    </span>
  );
}

export type DemoSteps = {
  /** Resolves after `ms`; never, once the demo has stopped. */
  wait: (ms: number) => Promise<void>;
  /** Glides the cursor to the middle of `target`, or `offset` from it (in the stage's px). */
  moveTo: (target: Element, offset?: { x: number; y: number }) => Promise<void>;
  /** Presses `target`: the cursor dips, the target shows itself pressed, `action` runs. */
  press: (target: Element, action: () => void) => Promise<void>;
  /** Moves the cursor off and fades it out. */
  leave: () => Promise<void>;
};

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

/**
 * Plays `script` on `stage` with the cursor. The function it returns stops the demo at once: the
 * step in progress never resolves, and the cursor fades away.
 */
export function playDemo(
  stage: HTMLElement,
  cursor: HTMLElement,
  script: (steps: DemoSteps) => Promise<void>,
) {
  let alive = true;
  let timer = 0;
  let pressed: Element | null = null;
  let at: Point = { x: 0, y: 0 };

  const wait = (ms: number) =>
    new Promise<void>((resolve) => {
      timer = window.setTimeout(() => {
        if (alive) resolve();
      }, ms);
    });

  /** A point on the screen, in the stage's own px (it may sit in a scaled container). */
  const toStage = (x: number, y: number): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (x - box.left) / scale, y: (y - box.top) / scale };
  };

  const place = (point: Point, ms: number) => {
    at = point;
    cursor.style.setProperty('--move', `${ms}ms`);
    cursor.style.setProperty('--x', `${point.x}px`);
    cursor.style.setProperty('--y', `${point.y}px`);
  };

  const moveTo = async (target: Element, offset: Point = { x: 0, y: 0 }) => {
    const box = target.getBoundingClientRect();
    const middle = toStage(box.left + box.width / 2, box.top + box.height / 2);
    const to = { x: middle.x + offset.x, y: middle.y + offset.y };
    if (cursor.dataset.state === 'hidden') {
      // Comes in from below and to the right of its first target, inside the stage.
      place(
        {
          x: Math.min(to.x + 70, stage.offsetWidth - 24),
          y: Math.min(to.y + 80, stage.offsetHeight - 24),
        },
        0,
      );
      // Applies that jump before the glide starts.
      cursor.getBoundingClientRect();
      cursor.dataset.state = 'shown';
    }
    // Longer trips take a little longer.
    const ms = Math.round(Math.min(900, Math.max(420, Math.hypot(to.x - at.x, to.y - at.y) * 2.2)));
    place(to, ms);
    await wait(ms + 40);
  };

  const press = async (target: Element, action: () => void) => {
    cursor.dataset.state = 'pressed';
    target.setAttribute('data-pressed', '');
    pressed = target;
    cursor.querySelector(`.${styles.ripple}`)?.animate(
      [
        { transform: 'scale(0.2)', opacity: 0.5 },
        { transform: 'scale(1)', opacity: 0 },
      ],
      { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    );
    await wait(130);
    action();
    cursor.dataset.state = 'shown';
    target.removeAttribute('data-pressed');
    pressed = null;
    await wait(120);
  };

  const leave = async () => {
    place({ x: at.x + 36, y: at.y + 44 }, 500);
    cursor.dataset.state = 'hidden';
    await wait(500);
  };

  void script({ wait, moveTo, press, leave });

  return () => {
    alive = false;
    window.clearTimeout(timer);
    cursor.dataset.state = 'hidden';
    pressed?.removeAttribute('data-pressed');
  };
}
```

### card-reveal/demo-cursor.module.css

```css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in its stage. The
   outer box carries the horizontal move, the inner one the vertical move, eased differently. Its
   colours are the stage's tokens (card-reveal.module.css). */

.cursor {
  --x: 0px;
  --y: 0px;
  --move: 0ms;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--x));
  transition:
    opacity 0.25s ease,
    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.vertical {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.arrow path {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ripple {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--pointer-ripple);
  opacity: 0;
}
```

### card-reveal/use-autoplay.ts

```tsx
'use client';

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

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

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

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

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