# Rank shields

> Game-style rank emblems in SVG: a battlemented shield with wings and an embossed number, in five metals, with a hover tilt and an unlock.

A podium of rank emblems, gold 1, silver 2 and bronze 3, each a battlemented shield with a bevelled panel, a heavy rounded numeral and two pairs of wings under a thick outline. Hover one and it leans towards the pointer as a band of light sweeps the metal; press it and it unlocks again: it drops in, the wings unfold, the number pops and sparkles flash. Any number, in gold, silver, bronze, platinum or diamond.

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

Category: Media.

## Anatomy

1. **Shield**: One emblem: a button when it has `onPress`, an image otherwise, named “Rank 1, Gold”. Hover tilts it up to 14°.
2. **Outline**: The frame and all four wings stroked together at 22 units under everything, so the gaps between them fill in.
3. **Wings**: Two pairs, upper and lower, drawn on the left and mirrored; each has a shaded underside and a lit top edge.
4. **Frame**: The battlemented shield, its left half lit and its right half shaded, with bevels along its top edges.
5. **Panel**: The recessed panel in the wings’ colour, with the shadow its rim casts across the top.
6. **Numeral**: Hand-drawn figures, each drawn three times: its drop, its lit edge, its face. Two figures or more scale down.

## When to use

- A rank or place earned in a game or a challenge: the top three of a leaderboard, a season tier, a level reached.
- A moment worth celebrating: the unlock (drop, wings, numeral, sparkles) when a rank is earned, or replayed on demand.
- A set of achievements still to earn, shown `locked` in grey until each is unlocked.
- An emblem that has to stay sharp at any size, from a 48 px list row to a full-width podium: it is one SVG.

## When not to use

- A count or a status in a dense interface. Use a badge or a pill; the emblem is art and wants room.
- Progress towards the next rank. Use a progress bar or a ring, and keep the shield for the rank itself.
- A long ranked list. Use a table with the place as a number; a shield on every row turns noisy past the first few.
- Serious work tools, where a game metaphor reads as a gimmick. Use a plain number or a medal icon.
- A rank with more than four figures: it is scaled down to fit the panel and stops reading at a glance. Show the number as text.

## Installation

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

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

export function LeaderboardRow({ place, unlocked }: { place: number; unlocked: boolean }) {
  const tier = place === 1 ? 'gold' : place === 2 ? 'silver' : 'bronze';
  return <RankShield rank={place} tier={tier} size={96} locked={!unlocked} />;
}
```

## States

- **Podium**: Gold 1, silver 2, bronze 3 at rest, with the demo off: `autoplay={false}`.
- **Tilted**: A hover leans the shield towards the pointer and lifts it 4%; here held with `pose`.
- **Locked**: Grey and dimmed, named “…, locked”. Turning `locked` false plays the unlock.
- **Platinum and diamond**: The two metals past gold, each one palette on the same drawing.
- **Several figures**: Two, three and four figures, scaled down together to fit the panel.
- **Small**: `size={48}`: the outline keeps it legible in a list row.
- **Light stage**: `colorScheme="light"`: the stage turns lavender grey; the emblems stay the same.
- **Dark stage**: `colorScheme="dark"`, or a dark page.

## How it works

- The emblem is one SVG drawn on a 461 × 340 grid, layered like a cut-out sticker: one thick outline under everything (the frame and all four wings stroked together at 22 units with round joins, so the gaps between the wings fill in), then the wings, the frame, its bevels, the recessed panel and the numeral. A drop shadow filter sits it on the stage.
- Symmetric shapes are written as their left half only; a small helper mirrors the segments to close each path, and the right wings are the left ones under a mirror transform, so both sides always match.
- The bevels are copies of the same paths: the frame’s light caught along its top edges is a stroke clipped to the frame, its inner lip is the panel shifted down and right in a lighter tone, the panel’s rim shadow is the panel shifted the other way inside a clip, and a mask leaves a dark edge down the frame’s right side. The left half of the frame is lit and the right half shaded by a hard-stop gradient.
- The numerals are hand-drawn outlines for 0 to 9 in a heavy, rounded style, each drawn three times: its drop (shifted down and right), its lit edge, and its face shifted inside a clip of itself. Numbers of two figures or more are scaled down to fit the panel.
- Each metal is one palette on the same drawing: outline, lit and shaded frame, bevel, lip, panel (shared by the wings), rim, wing shade, and the numeral’s three tones.
- Hover tilts the emblem’s HTML box with a perspective rotation, up to 14° towards the pointer, written to custom properties without re-rendering. Entering it, or focusing it from the keyboard, sweeps a skewed band of light across the metal with the Web Animations API, clipped to the frame and wings.
- The unlock remounts the drawing, which restarts its CSS keyframes: the shield drops 46 units and settles in 620 ms, the upper wings unfold from behind it at 320 ms and the lower ones at 410 ms (from their inner edge, with an overshoot), the numeral pops at 620 ms, and a glow and five sparkles flash from 780 ms. Everything moves by transform and opacity.
- The demo plays the unlock across the podium, then leans one shield after another as if hovered; any real input hands it to the reader. It pauses off screen and in a hidden tab, and under reduced motion nothing tilts, sweeps or animates.

## API reference

- **rank** (`number`): RankShield: the number on the shield. Whole numbers; two figures or more are scaled down to fit.
- **tier** (`'gold' | 'silver' | 'bronze' | 'platinum' | 'diamond'`, default `'gold'`): RankShield: the metal.
- **size** (`number | string`, default `'100%'`): RankShield: its width (a number is px); the height follows, about three quarters of it.
- **locked** (`boolean`, default `false`): RankShield: shown grey and dimmed, as not yet earned. Turning it false plays the unlock.
- **replay** (`number`): RankShield: each new value plays the unlock again.
- **shine** (`number`): RankShield: each new value sweeps the sheen across, as a hover does.
- **pose** (`{ x: number; y: number } | null`): RankShield: holds the tilt at this pose, -1 to 1 on each axis, instead of following the pointer.
- **onPress** (`() => void`): RankShield: makes the shield a button, called when pressed.
- **label** (`string`): RankShield: the accessible name; by default “Rank 1, Gold”.
- **shields** (`{ rank: number; tier: Tier }[]`, default `gold 1, silver 2, bronze 3`): RankShields: the emblems on the stage, left to right.
- **autoplay** (`boolean`, default `true`): RankShields: while on screen and untouched, replays the unlock and leans the shields now and then.
- **colorScheme** (`'light' | 'dark' | 'system'`): RankShields: light or dark stage, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Classes for the root element.

## Accessibility

- Each emblem is one image to assistive technology, named “Rank 1, Gold” (or by its label); the drawing itself is hidden. With onPress it is a real button with that name instead.
- The shields and the Replay button are reachable with Tab, show a focus ring on :focus-visible, and play with Enter or Space; keyboard focus also sweeps the sheen.
- Pressing a shield or Replay is announced through a polite live region (“Rank 1, Gold, unlocked”).
- Under prefers-reduced-motion the tilt, the sheen and the unlock are all off, and the demo never plays; off screen or in a hidden tab it pauses.
- The numeral keeps strong contrast with its panel in every metal, and the thick outline keeps the emblem legible on light and dark stages.

## Source

### rank-shields/rank-shields.tsx

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

'use client';

import { useEffect, useRef, useState } from 'react';
import { RankShield } from './rank-shield';
import { TIER_NAMES, type Tier } from './tiers';
import { useAutoplay } from './use-autoplay';
import styles from './rank-shields.module.css';

export { RankShield, type RankShieldProps } from './rank-shield';
export { TIERS, type Tier } from './tiers';

/*
 * Rank shields: a podium of game-style rank emblems (gold 1, silver 2, bronze 3) on a dark navy
 * stage. Each emblem is a RankShield (rank-shield.tsx), any number in any of five metals.
 *
 * - Each shield is a button: hovering tilts it towards the pointer and sweeps a sheen across it,
 *   focusing it from the keyboard sweeps the sheen, pressing it plays its unlock again. "Replay"
 *   plays all three, one after another.
 * - While on screen and untouched, the demo plays the unlock once, then now and then leans one
 *   shield after another as if hovered, sweeping its sheen, and every few rounds plays the unlock
 *   again. Any real input hands it to the reader. Off screen, in a hidden tab or under reduced
 *   motion, nothing plays.
 * - Light or dark: the stage is navy at night and a pale lavender grey by day; the emblems are the
 *   same on both.
 */

type Entry = { rank: number; tier: Tier };

const PODIUM: Entry[] = [
  { rank: 1, tier: 'gold' },
  { rank: 2, tier: 'silver' },
  { rank: 3, tier: 'bronze' },
];

/** The stagger between the shields' unlocks, ms. */
const STAGGER = 180;
/** One round of the demo: a lean on the next shield. */
const ROUND = 2400;
/** The poses of a demo lean, each held for a part of the round. */
const LEAN = [
  { x: -0.6, y: -0.45 },
  { x: 0.55, y: -0.2 },
  { x: 0.15, y: 0.5 },
];

export function RankShields({
  shields = PODIUM,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The emblems on the stage, left to right. */
  shields?: Entry[];
  /** While on screen and untouched, replays the unlock and leans the shields now and then. */
  autoplay?: boolean;
  /** Light or dark stage, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [replays, setReplays] = useState<number[]>(() => shields.map(() => 0));
  const [shines, setShines] = useState<number[]>(() => shields.map(() => 0));
  const [pose, setPose] = useState<{ index: number; x: number; y: number } | null>(null);
  const [message, setMessage] = useState('');
  const timers = useRef<number[]>([]);
  const played = useRef(false);

  const later = (ms: number, run: () => void) => {
    timers.current.push(window.setTimeout(run, ms));
  };
  const clear = () => {
    timers.current.forEach((timer) => window.clearTimeout(timer));
    timers.current = [];
  };
  useEffect(() => clear, []);

  const bump = (list: number[], index: number) =>
    list.map((value, at) => (at === index ? value + 1 : value));

  const unlock = (index: number) => setReplays((now) => bump(now, index));
  const unlockAll = () =>
    shields.forEach((_, index) => later(index * STAGGER, () => unlock(index)));

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  // The demo.
  const demo = autoplay && playing;
  useEffect(() => {
    if (!demo) {
      setPose(null);
      return;
    }
    let round = 0;
    let timer = 0;
    const step = () => {
      const index = round % shields.length;
      if (!played.current || (round > 0 && round % (shields.length * 2) === 0)) {
        played.current = true;
        setPose(null);
        unlockAll();
      } else {
        LEAN.forEach((lean, at) =>
          later((at * ROUND) / (LEAN.length + 1), () => setPose({ index, ...lean })),
        );
        later(80, () => setShines((now) => bump(now, index)));
        later((LEAN.length * ROUND) / (LEAN.length + 1), () => setPose(null));
      }
      round++;
      timer = window.setTimeout(step, ROUND);
    };
    timer = window.setTimeout(step, 400);
    return () => {
      window.clearTimeout(timer);
      clear();
      setPose(null);
    };
    // The demo restarts only when it may play again.
  }, [demo, shields.length]);

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-part="stage"
      onPointerDown={take}
      onKeyDown={take}
    >
      <ul className={styles.row} data-part="podium" aria-label="Ranks">
        {shields.map((entry, index) => (
          <li key={index} className={styles.place}>
            <RankShield
              rank={entry.rank}
              tier={entry.tier}
              replay={replays[index]}
              shine={shines[index]}
              pose={pose && pose.index === index ? pose : null}
              onPress={() => {
                take();
                clear();
                setPose(null);
                unlock(index);
                announce(`Rank ${entry.rank}, ${TIER_NAMES[entry.tier]}, unlocked`);
              }}
            />
          </li>
        ))}
      </ul>
      <div className={styles.bar}>
        <button
          type="button"
          className={styles.replay}
          data-part="replay"
          onClick={() => {
            take();
            clear();
            setPose(null);
            unlockAll();
            announce('All ranks unlocked');
          }}
        >
          <svg viewBox="0 0 16 16" aria-hidden="true" className={styles.replayIcon}>
            <path
              d="M3.5 8a4.5 4.5 0 1 0 1.4-3.26M3.5 2.5v2.6h2.6"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.6"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
          Replay
        </button>
      </div>
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### rank-shields/rank-shield.tsx

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

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  DIGITS,
  FRAME,
  GLYPH,
  MIRROR,
  PANEL,
  SPARKLES,
  STAR,
  TOP_EDGE,
  VIEW,
  WING_LOWER,
  WING_UPPER,
} from './geometry';
import { TIER_NAMES, TIERS, type Tier } from './tiers';
import styles from './rank-shields.module.css';

/*
 * One rank shield: a battlemented shield with a bevelled panel, an embossed numeral and two pairs
 * of wings, in one of five metals. Drawn in SVG, so it is sharp at any size.
 *
 * - The drawing is layered like a cut-out sticker: one thick outline under everything (the frame
 *   and the four wings stroked together, so the gaps between them fill in), the wings, the frame
 *   (its left half lit, its right half shaded), the frame's bevels, the recessed panel with the
 *   shadow of its rim, and the numeral, drawn three times: its drop, its lit edge, its face.
 * - Hover tilts it towards the pointer (a 3D rotation of the whole picture) and sweeps a band of
 *   light across the metal, clipped to the frame and the wings. Keyboard focus does the same
 *   sweep. Under reduced motion, neither moves.
 * - The unlock: the shield drops in and settles, the wings unfold from behind it, upper then
 *   lower, the numeral pops, and sparkles flash around it. It plays when `locked` turns false, or
 *   whenever `replay` changes. Every part animates by transform and opacity only, from CSS
 *   keyframes started by remounting the drawing.
 */

export type RankShieldProps = {
  /** The number on the shield. Whole numbers, up to four figures; longer ones are scaled down. */
  rank: number;
  /** The metal. */
  tier?: Tier;
  /** Its width (a number is px); the height follows, about three quarters of it. */
  size?: number | string;
  /** Shown dimmed and grey, as not yet earned. Turning it false plays the unlock. */
  locked?: boolean;
  /** Each new value plays the unlock again. */
  replay?: number;
  /** Each new value sweeps the sheen across, as a hover does. */
  shine?: number;
  /** Holds the tilt at this pose, -1 to 1 on each axis (as if pointed at), instead of the pointer's. */
  pose?: { x: number; y: number } | null;
  /** Makes the shield a button. */
  onPress?: () => void;
  /** The accessible name; by default "Rank 1, Gold". */
  label?: string;
  className?: string;
  style?: CSSProperties;
};

/** How far the hover tilts the shield, in degrees. */
const TILT = 14;

function digitsOf(rank: number) {
  const whole = Math.round(Math.abs(rank));
  return Number.isFinite(whole) ? String(whole) : '0';
}

export function RankShield({
  rank,
  tier = 'gold',
  size,
  locked = false,
  replay,
  shine,
  pose,
  onPress,
  label,
  className = '',
  style,
}: RankShieldProps) {
  const id = useId().replace(/:/g, '');
  const palette = TIERS[tier] ?? TIERS.gold;
  const root = useRef<HTMLElement>(null);
  const sheen = useRef<SVGRectElement>(null);
  /** How many unlocks have played: the drawing remounts on each, which restarts its keyframes. */
  const [run, setRun] = useState(0);
  const last = useRef({ locked, replay });

  useEffect(() => {
    const before = last.current;
    last.current = { locked, replay };
    if ((before.locked && !locked) || (replay !== undefined && replay !== before.replay))
      setRun((count) => count + 1);
  }, [locked, replay]);

  const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  /** Sweeps the band of light across the metal once. */
  const sweep = (delay = 0) => {
    const band = sheen.current;
    if (!band || reduced() || !band.animate) return;
    band.animate(
      [
        { transform: 'translateX(-260px) skewX(-18deg)' },
        { transform: 'translateX(420px) skewX(-18deg)' },
      ],
      { duration: 820, delay, easing: 'cubic-bezier(0.45, 0.05, 0.25, 1)' },
    );
  };

  useEffect(() => {
    if (shine !== undefined) sweep();
  }, [shine]);
  useEffect(() => {
    if (run) sweep(980);
  }, [run]);

  /** Tilts towards a pose, -1 to 1 on each axis; null lets go. */
  const tilt = (x: number | null, y = 0) => {
    const element = root.current;
    if (!element) return;
    if (x === null) {
      element.style.removeProperty('--rx');
      element.style.removeProperty('--ry');
      element.removeAttribute('data-tilted');
      return;
    }
    element.style.setProperty('--ry', `${(x * TILT).toFixed(2)}deg`);
    element.style.setProperty('--rx', `${(-y * TILT).toFixed(2)}deg`);
    element.setAttribute('data-tilted', '');
  };

  const poseX = pose?.x;
  const poseY = pose?.y;
  useEffect(() => {
    if (poseX === undefined || poseY === undefined) tilt(null);
    else if (!reduced()) tilt(poseX, poseY);
  }, [poseX, poseY]);

  const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
    if (event.pointerType === 'touch' || pose || reduced()) return;
    const box = event.currentTarget.getBoundingClientRect();
    const x = ((event.clientX - box.left) / box.width) * 2 - 1;
    const y = ((event.clientY - box.top) / box.height) * 2 - 1;
    tilt(Math.max(-1, Math.min(1, x)), Math.max(-1, Math.min(1, y)));
  };
  const onPointerEnter = (event: ReactPointerEvent<HTMLElement>) => {
    if (event.pointerType !== 'touch') sweep();
  };
  const onPointerLeave = () => {
    if (!pose) tilt(null);
  };

  const name =
    label ?? `Rank ${digitsOf(rank)}, ${TIER_NAMES[tier] ?? tier}${locked ? ', locked' : ''}`;
  const width = typeof size === 'number' ? `${size}px` : size;
  const common = {
    className: `${styles.shield} ${className}`,
    'data-tier': tier,
    'data-locked': locked ? '' : undefined,
    'data-part': 'shield',
    style: { ...(width ? { '--size': width } : null), ...style } as CSSProperties,
    onPointerMove,
    onPointerEnter,
    onPointerLeave,
  };
  const art = (
    <span className={styles.tilt} aria-hidden="true">
      <Drawing key={run} id={id} rank={rank} palette={palette} unlock={run > 0} sheen={sheen} />
    </span>
  );

  if (onPress)
    return (
      <button
        {...common}
        ref={root as React.Ref<HTMLButtonElement>}
        type="button"
        aria-label={name}
        onClick={onPress}
        onFocus={(event) => {
          if (event.currentTarget.matches(':focus-visible')) sweep();
        }}
      >
        {art}
      </button>
    );
  return (
    <span {...common} ref={root as React.Ref<HTMLSpanElement>} role="img" aria-label={name}>
      {art}
    </span>
  );
}

function Drawing({
  id,
  rank,
  palette,
  unlock,
  sheen,
}: {
  id: string;
  rank: number;
  palette: (typeof TIERS)[Tier];
  unlock: boolean;
  sheen: React.RefObject<SVGRectElement | null>;
}) {
  const ref = (name: string) => `${id}-${name}`;
  const url = (name: string) => `url(#${ref(name)})`;

  // The numeral: one glyph box per figure, side by side, scaled to fit the panel and centred on
  // the single figure's place.
  const figures = digitsOf(rank).split('');
  const span = GLYPH.advance * (figures.length - 1) + GLYPH.width;
  const scale = Math.min(1, 150 / span);
  const left = GLYPH.x + GLYPH.width / 2 - (span * scale) / 2;
  const top = GLYPH.y + (GLYPH.height * (1 - scale)) / 2;
  const glyphs = figures.map((figure, index) => {
    const digit = DIGITS[figure] ?? DIGITS['0'];
    const x = left + index * GLYPH.advance * scale;
    return (
      <path
        key={index}
        d={digit.d}
        transform={`translate(${x.toFixed(2)} ${top.toFixed(2)}) scale(${scale.toFixed(4)})${
          digit.transform ? ' ' + digit.transform : ''
        }`}
      />
    );
  });

  const wing = (side: 'left' | 'right') => (
    <g transform={side === 'right' ? MIRROR : undefined}>
      <g className={styles.wingUpper}>
        <path d={WING_UPPER} fill={palette.wingShade} />
        <g clipPath={url('wing-upper')}>
          <path d={WING_UPPER} transform="translate(2 -4.5)" fill={palette.panel} />
          <path d="M30 87.5L106 96.5" stroke={palette.wingEdge} strokeWidth="4" fill="none" />
        </g>
      </g>
      <g className={styles.wingLower}>
        <path d={WING_LOWER} fill={palette.wingShade} />
        <g clipPath={url('wing-lower')}>
          <path d={WING_LOWER} transform="translate(2 -4.5)" fill={palette.panel} />
          <path d="M55 178.5L110 184.5" stroke={palette.wingEdge} strokeWidth="4" fill="none" />
        </g>
      </g>
    </g>
  );
  const wingOutline = (side: 'left' | 'right') => (
    <g transform={side === 'right' ? MIRROR : undefined}>
      <path className={styles.wingUpper} d={WING_UPPER} />
      <path className={styles.wingLower} d={WING_LOWER} />
    </g>
  );

  return (
    <svg
      className={styles.art}
      viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
      data-unlock={unlock ? '' : undefined}
      focusable="false"
    >
      <defs>
        <linearGradient id={ref('frame')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0.5" stopColor={palette.frame} />
          <stop offset="0.5" stopColor={palette.frameShade} />
        </linearGradient>
        <linearGradient id={ref('sheen')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#fff" stopOpacity="0" />
          <stop offset="0.45" stopColor="#fff" stopOpacity="0.5" />
          <stop offset="0.55" stopColor="#fff" stopOpacity="0.5" />
          <stop offset="1" stopColor="#fff" stopOpacity="0" />
        </linearGradient>
        <radialGradient id={ref('glow')}>
          <stop offset="0" stopColor={palette.glow} stopOpacity="0.75" />
          <stop offset="1" stopColor={palette.glow} stopOpacity="0" />
        </radialGradient>
        <clipPath id={ref('frame-clip')}>
          <path d={FRAME} />
        </clipPath>
        <clipPath id={ref('panel-clip')}>
          <path d={PANEL} />
        </clipPath>
        <clipPath id={ref('wing-upper')}>
          <path d={WING_UPPER} />
        </clipPath>
        <clipPath id={ref('wing-lower')}>
          <path d={WING_LOWER} />
        </clipPath>
        <clipPath id={ref('metal')}>
          <path d={FRAME} />
          <path d={WING_UPPER} />
          <path d={WING_LOWER} />
          <path d={WING_UPPER} transform={MIRROR} />
          <path d={WING_LOWER} transform={MIRROR} />
        </clipPath>
        <clipPath id={ref('numeral')}>{glyphs}</clipPath>
        <mask id={ref('edge')} maskUnits="userSpaceOnUse" x="0" y="0" width="461" height="340">
          <rect width="461" height="340" fill="#fff" />
          <path d={FRAME} transform="translate(-3.5 -1)" fill="#000" />
        </mask>
        <filter id={ref('drop')} x="-10%" y="-10%" width="125%" height="125%">
          <feDropShadow dx="10" dy="9" stdDeviation="1.6" floodColor="#000" floodOpacity="0.24" />
        </filter>
      </defs>

      <g className={styles.burst} data-part="glow">
        <circle cx="230.5" cy="170" r="190" fill={url('glow')} />
      </g>

      <g className={styles.body} filter={url('drop')}>
        <g
          data-part="outline"
          fill={palette.outline}
          stroke={palette.outline}
          strokeWidth="22"
          strokeLinejoin="round"
        >
          <path d={FRAME} />
          <path d={FRAME} transform="translate(0 3.5)" />
          {wingOutline('left')}
          {wingOutline('right')}
        </g>

        <g data-part="wings">
          {wing('left')}
          {wing('right')}
        </g>

        <g data-part="frame">
          <path d={FRAME} fill={url('frame')} />
          <path d={FRAME} fill={palette.deep} mask={url('edge')} />
          <g clipPath={url('frame-clip')}>
            <g fill="none" stroke={palette.bevel} strokeWidth="9" strokeLinejoin="round">
              <path d={TOP_EDGE} />
              <path d={TOP_EDGE} transform={MIRROR} />
            </g>
            <ellipse
              cx="118"
              cy="52"
              rx="16"
              ry="9"
              fill={palette.bevel}
              transform="rotate(-20 118 52)"
            />
            <path d={PANEL} transform="translate(2 6)" fill={palette.lip} />
            <path d={PANEL} transform="translate(5 3)" fill={palette.lip} />
          </g>
        </g>

        <g data-part="panel">
          <path d={PANEL} fill={palette.rim} />
          <g clipPath={url('panel-clip')}>
            <path d={PANEL} transform="translate(4 4.5)" fill={palette.panel} />
          </g>
        </g>

        <g className={styles.numeral} data-part="numeral">
          <g transform="translate(4 3.5)" fill={palette.numeralShadow}>
            {glyphs}
          </g>
          <g fill={palette.numeralLight}>{glyphs}</g>
          <g clipPath={url('numeral')}>
            <g transform="translate(2.5 3)" fill={palette.numeral}>
              {glyphs}
            </g>
          </g>
        </g>

        <g clipPath={url('metal')}>
          <rect
            ref={sheen}
            className={styles.sheen}
            x="0"
            y="-20"
            width="90"
            height="380"
            fill={url('sheen')}
          />
        </g>
      </g>

      <g className={styles.sparkles} fill={palette.glow} data-part="sparkles">
        {SPARKLES.map((spark, index) => (
          <g key={index} transform={`translate(${spark.x} ${spark.y}) scale(${spark.r})`}>
            <path
              className={styles.spark}
              d={STAR}
              style={{ animationDelay: `${860 + spark.delay}ms` }}
            />
          </g>
        ))}
      </g>
    </svg>
  );
}
```

### rank-shields/geometry.ts

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

/*
 * The drawing of a rank shield, in the units of a 461 × 340 view box whose mirror axis is
 * x = 230.5. Every symmetric shape is drawn as its left half only; `symmetric()` closes it with
 * the right half, mirrored. The wings are drawn on the left and mirrored as a whole.
 */

export const VIEW = { width: 461, height: 340 };
const AXIS = 461;

type Point = [number, number];
type Segment = { c?: [Point, Point]; q?: Point; to: Point };

const n = (value: number) => +value.toFixed(2);
const pt = ([x, y]: Point) => `${n(x)} ${n(y)}`;
const flip = ([x, y]: Point): Point => [AXIS - x, y];

/** A closed path from its left half: from a point on the axis, down the left, to the axis again. */
function symmetric(start: Point, half: Segment[]) {
  let d = `M${pt(start)}`;
  for (const segment of half) {
    if (segment.c) d += `C${pt(segment.c[0])} ${pt(segment.c[1])} ${pt(segment.to)}`;
    else if (segment.q) d += `Q${pt(segment.q)} ${pt(segment.to)}`;
    else d += `L${pt(segment.to)}`;
  }
  // Back up the right side: the same segments, mirrored, in reverse order.
  for (let i = half.length - 1; i >= 0; i--) {
    const segment = half[i];
    const to = flip(i === 0 ? start : half[i - 1].to);
    if (segment.c) d += `C${pt(flip(segment.c[1]))} ${pt(flip(segment.c[0]))} ${pt(to)}`;
    else if (segment.q) d += `Q${pt(flip(segment.q))} ${pt(to)}`;
    else d += `L${pt(to)}`;
  }
  return d + 'Z';
}

/** The outer frame: a battlemented shield, a tower in the middle and a merlon on each side. */
export const FRAME = symmetric(
  [230.5, 22],
  [
    {
      c: [
        [214, 22],
        [201, 23.5],
        [196, 24.5],
      ],
      to: [196, 24.5],
    },
    { q: [190, 25.5], to: [190, 31] },
    { to: [190, 51] },
    { to: [161, 59.5] },
    { q: [157, 60.5], to: [157, 56] },
    { to: [157, 40] },
    { q: [157, 35.5], to: [152.5, 36.8] },
    { to: [116, 48] },
    {
      c: [
        [106, 51],
        [103.5, 55],
        [103, 62],
      ],
      to: [103, 62],
    },
    {
      c: [
        [100, 90],
        [95.5, 120],
        [95.5, 155],
      ],
      to: [95.5, 155],
    },
    {
      c: [
        [95.5, 190],
        [99, 222],
        [103, 240],
      ],
      to: [103, 240],
    },
    { q: [104.5, 247], to: [112, 252] },
    {
      c: [
        [150, 279.5],
        [198, 306],
        [230.5, 315],
      ],
      to: [230.5, 315],
    },
  ].map(fixCubic),
);

/** The recessed panel inside the frame. */
export const PANEL = symmetric(
  [230.5, 69],
  [
    {
      c: [
        [200, 69.5],
        [168, 73.5],
        [148, 80.5],
      ],
      to: [148, 80.5],
    },
    {
      c: [
        [134, 85],
        [127.5, 90],
        [127, 102],
      ],
      to: [127, 102],
    },
    {
      c: [
        [125.5, 140],
        [125.5, 195],
        [129.5, 226],
      ],
      to: [129.5, 226],
    },
    { q: [132, 232], to: [139, 236] },
    {
      c: [
        [175, 256],
        [205, 272],
        [230.5, 281],
      ],
      to: [230.5, 281],
    },
  ].map(fixCubic),
);

/** The top edges of the frame, which catch the light: merlon, notch, tower (left half). */
export const TOP_EDGE =
  'M104 60C106 51 110 49.5 116 48L152.5 36.8Q157 35.5 157 40L157 56Q157 60.5 161 59.5L190 51L190 31Q190 25.5 196 24.5C201 23.5 214 22 230.5 22';

/** The left wings; the right ones are the same, mirrored about the axis. */
export const WING_UPPER =
  'M34 87L106 96L106 183C82 182 62 177 50 160C40 146 32 125 29 102Q28 87 34 87Z';
export const WING_LOWER =
  'M59 178L110 184L110 254C92 252 80 248 71 236C64 227 58 214 56 200L55 186Q54.5 178 59 178Z';
/** Mirrors a left-hand drawing onto the right. */
export const MIRROR = `matrix(-1 0 0 1 ${AXIS} 0)`;

// `symmetric` takes cubic control points with the end point repeated in `to`; the literals above
// spell it twice for readability, this keeps the type happy.
function fixCubic(segment: { c?: number[][]; q?: number[]; to: number[] }): Segment {
  return {
    c: segment.c ? [segment.c[0] as Point, segment.c[1] as Point] : undefined,
    q: segment.q as Point | undefined,
    to: segment.to as Point,
  };
}

/*
 * The numerals: a heavy, rounded display figure, drawn in a 86 × 131 box. A single figure sits
 * with its box's top-left at (188, 104); longer numbers are scaled down to fit the panel.
 */
export const GLYPH = { width: 86, height: 131, x: 188, y: 104, advance: 82 };

export const DIGITS: Record<string, { d: string; transform?: string }> = {
  '0': {
    d: 'M43 0C70 0 84 25 84 65.5C84 106 70 131 43 131C16 131 2 106 2 65.5C2 25 16 0 43 0ZM43 36C37 36 36 48 36 65.5C36 83 37 95 43 95C49 95 50 83 50 65.5C50 48 49 36 43 36Z',
  },
  '1': {
    d: 'M7 2H58.5Q63.5 2 63.5 7V124Q63.5 129 58.5 129H31Q26 129 26 124V38H7Q2 38 2 33V7Q2 2 7 2Z',
  },
  '2': {
    d: 'M42 0C67 0 83 13 83 32C83 45 79 56 73 65C66 75 60 84 53 93Q51 98.5 55 98.5L81 96.5Q84 96.3 84 100L84 122Q84 129 77 129L13 129Q2 129 2 118L2 109C2 103 5 100 9 96C22 83 39 65 44 50L44 44C44 39 42 36 38 36L26 36L15 41Q11 42 9 38C5 34 2 28 2 20C2 8 18 0 42 0Z',
  },
  '3': {
    d: 'M40 0C64 0 80 10 80 28C80 41 75 50 67 56C64 58.5 63 60.5 63 62C73 66 83.5 78 83.5 95C83.5 118 66 131.5 40 131.5C15 131.5 -1 126 -1.5 117C-2 109 3 99 9 92L22 97Q26 98 32 97L38 97C43 96 46.5 93 46.5 88C46.5 83 43 79 38 79L22 79Q18 79 18 75L18 57Q18 53 22 53L34 53C40 52 44 49 44 45C44 39 40 35 34 35L24 36L12 40.5Q9 41.5 7.5 37.5L2.5 26C-2 10 10 0 40 0Z',
  },
  '4': {
    d: 'M46 2L76 2Q80 2 80 6L80 78L82 78Q86 78 86 82L86 104Q86 108 82 108L80 108L80 125Q80 129 76 129L50 129Q46 129 46 125L46 108L6 108Q2 108 2 104L2 86Q2 82 4 79L40 6Q42 2 46 2ZM46 48L30 78L46 78Z',
  },
  '5': {
    d: 'M12 2L76 2Q80 2 80 6L80 30Q80 34 76 34L39 34L38 45C41 44 45 43 50 43C71 43 84 58 84 85C84 113 66 131 40 131C20 131 6 123 0 112L18 92C24 97 30 98 36 98C44 98 48 94 48 87C48 80 44 76 37 76C31 76 27 78 24 81L5 77L8 6Q8 2 12 2Z',
  },
  '6': {
    d: 'M56 0L70 24C57 27 46 33 41 45C44 43 48 42 52 42C72 42 84 58 84 85C84 112 68 131 43 131C18 131 2 112 2 84C2 46 22 12 56 0ZM43 72C38 72 36 78 36 86C36 94 38 100 43 100C48 100 50 94 50 86C50 78 48 72 43 72Z',
  },
  '7': {
    d: 'M6 2L80 2Q84 2 84 6L84 28Q84 32 82 36L46 126Q44 129 40 129L14 129Q9 129 11 124L46 36L6 36Q2 36 2 32L2 6Q2 2 6 2Z',
  },
  '8': {
    d: 'M43 0C66 0 80 12 80 32C80 45 74 54 66 60C77 66 84 77 84 92C84 116 66 131 43 131C20 131 2 116 2 92C2 77 9 66 20 60C12 54 6 45 6 32C6 12 20 0 43 0ZM43 29C38 29 36 33 36 38C36 44 39 48 43 48C47 48 50 44 50 38C50 33 48 29 43 29ZM43 76C38 76 35 80 35 88C35 96 38 100 43 100C48 100 51 96 51 88C51 80 48 76 43 76Z',
  },
};
DIGITS['9'] = { d: DIGITS['6'].d, transform: 'rotate(180 43 65.5)' };

/** Where the sparkles of the unlock land, and how large they are. */
export const SPARKLES: { x: number; y: number; r: number; delay: number }[] = [
  { x: 120, y: 34, r: 16, delay: 0 },
  { x: 372, y: 70, r: 12, delay: 90 },
  { x: 404, y: 258, r: 15, delay: 170 },
  { x: 70, y: 274, r: 11, delay: 240 },
  { x: 300, y: 18, r: 9, delay: 300 },
];

/** A four-pointed star of radius 1 at the origin. */
export const STAR =
  'M0 -1C0.12 -0.3 0.3 -0.12 1 0C0.3 0.12 0.12 0.3 0 1C-0.12 0.3 -0.3 0.12 -1 0C-0.3 -0.12 -0.12 -0.3 0 -1Z';
```

### rank-shields/tiers.ts

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

/*
 * The metals. Each tier is one set of colours on the same drawing: the outline, the frame's lit
 * (left) and shaded (right) halves, its bevels, the recessed panel and its rim (the wings share the
 * panel's colour), and the numeral's highlight, face and drop.
 */

export type Tier = 'gold' | 'silver' | 'bronze' | 'platinum' | 'diamond';

export type Palette = {
  /** The thick outline around everything. */
  outline: string;
  /** The frame, lit half and shaded half. */
  frame: string;
  frameShade: string;
  /** The light caught by the top edges, and by the frame's inner lip along the bottom. */
  bevel: string;
  lip: string;
  /** The deep shadow along the frame's right edge, where it meets the wings. */
  deep: string;
  /** The recessed panel (and the wings), and the shadow its rim casts on it. */
  panel: string;
  rim: string;
  /** The underside of a wing. */
  wingShade: string;
  /** The lit top edge of a wing. */
  wingEdge: string;
  /** The numeral: its lit edge, its face, its drop. */
  numeralLight: string;
  numeral: string;
  numeralShadow: string;
  /** The glow behind an unlock, and its sparkles. */
  glow: string;
};

export const TIERS: Record<Tier, Palette> = {
  gold: {
    deep: '#8a3600',
    outline: '#702001',
    frame: '#fecf4c',
    frameShade: '#fbbd0b',
    bevel: '#fff0b0',
    lip: '#ffe88a',
    panel: '#e1850b',
    rim: '#b9550f',
    wingShade: '#b55420',
    wingEdge: '#f19e38',
    numeralLight: '#fff694',
    numeral: '#fbbd08',
    numeralShadow: '#b65a03',
    glow: '#ffd75e',
  },
  silver: {
    deep: '#173741',
    outline: '#2f4a56',
    frame: '#c5e0e2',
    frameShade: '#acd1d1',
    bevel: '#e2fdff',
    lip: '#d2f4f6',
    panel: '#8ca3b2',
    rim: '#425b69',
    wingShade: '#5e7684',
    wingEdge: '#b5d1de',
    numeralLight: '#d4f5f9',
    numeral: '#a9d2d3',
    numeralShadow: '#38505d',
    glow: '#cff5ff',
  },
  bronze: {
    deep: '#4f0a00',
    outline: '#6d1a02',
    frame: '#e0977c',
    frameShade: '#d98163',
    bevel: '#ffbea2',
    lip: '#f8ab8b',
    panel: '#b04c3e',
    rim: '#8b392c',
    wingShade: '#882c1a',
    wingEdge: '#d26b57',
    numeralLight: '#f8a58d',
    numeral: '#df8a6c',
    numeralShadow: '#843a26',
    glow: '#ffb08f',
  },
  platinum: {
    deep: '#271b45',
    outline: '#3a2c58',
    frame: '#ebe6f7',
    frameShade: '#d0c7ea',
    bevel: '#fdfcff',
    lip: '#f5f1ff',
    panel: '#8f82b8',
    rim: '#584b82',
    wingShade: '#665895',
    wingEdge: '#beb3df',
    numeralLight: '#ffffff',
    numeral: '#ded6f4',
    numeralShadow: '#45386c',
    glow: '#e6dcff',
  },
  diamond: {
    deep: '#061d45',
    outline: '#0e2d5a',
    frame: '#93e4ff',
    frameShade: '#52c3f2',
    bevel: '#e0f9ff',
    lip: '#c2f2ff',
    panel: '#2b78c8',
    rim: '#17468a',
    wingShade: '#1b56a0',
    wingEdge: '#72b8ee',
    numeralLight: '#ecfcff',
    numeral: '#a2e9ff',
    numeralShadow: '#123e79',
    glow: '#9becff',
  },
};

export const TIER_NAMES: Record<Tier, string> = {
  gold: 'Gold',
  silver: 'Silver',
  bronze: 'Bronze',
  platinum: 'Platinum',
  diamond: 'Diamond',
};
```

### rank-shields/use-autoplay.ts

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

'use client';

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

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

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

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

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

### rank-shields/rank-shields.module.css

```css
/* Rank shields (rank-shields.tsx, rank-shield.tsx): a navy stage at night, a pale lavender grey by
   day, with three emblems in a row, as large as the stage allows (a container query), and a small
   Replay button under them. The emblems' own colours are in tiers.ts: they are art, the same on
   both schemes.

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

   Motion. Everything moves by transform and opacity: the tilt (a 3D rotation of each emblem's
   HTML box), the sheen (a band of light translated across, clipped to the metal) and the unlock's
   keyframes on the drawing's groups. */

.root {
  /* The stage. */
  --surface: #e9e8f1;
  /* The Replay button, its words and its edge. */
  --ink: #2b2a3d;
  --button: rgb(255 255 255 / 0.7);
  --button-hover: #fff;
  --edge: rgb(30 28 60 / 0.12);
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(8px, 3cqi, 28px);
  width: 100%;
  min-height: 400px;
  padding: clamp(20px, 6cqi, 56px) clamp(12px, 4cqi, 40px) clamp(16px, 4cqi, 32px);
  box-sizing: border-box;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  color: var(--ink);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e9e8f1, #343346);
    --ink: light-dark(#2b2a3d, #e4e3f0);
    --button: light-dark(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.06));
    --button-hover: light-dark(#fff, rgb(255 255 255 / 0.12));
    --edge: light-dark(rgb(30 28 60 / 0.12), rgb(255 255 255 / 0.1));
    --focus: light-dark(#387bff, #7aa7ff);
  }
}
.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;
}

.row {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: center;
  /* Between the emblems' outlines: about a quarter of one, as on a podium sheet. */
  gap: clamp(4px, 3.2cqi, 40px);
  width: min(100%, 860px);
  margin: 0;
  padding: 0;
  list-style: none;
}
.place {
  display: grid;
  place-items: center;
  min-width: 0;
}

/* One emblem. The drawing's box is a bit wider than its outline, so the emblems sit close. */
.shield {
  --rx: 0deg;
  --ry: 0deg;
  position: relative;
  display: inline-block;
  width: var(--size, 100%);
  max-width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 24%;
  background: none;
  color: inherit;
  font: inherit;
  line-height: 0;
  vertical-align: middle;
  cursor: default;
  -webkit-tap-highlight-color: transparent;
}
button.shield {
  cursor: pointer;
}
.shield:focus-visible {
  outline: 2px solid var(--focus, #387bff);
  outline-offset: 2px;
}
.tilt {
  display: block;
  transform: perspective(700px) rotateX(var(--rx)) rotateY(var(--ry));
  transition: transform 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.shield[data-tilted] .tilt {
  transform: perspective(700px) rotateX(var(--rx)) rotateY(var(--ry)) translateY(-2%) scale(1.04);
  transition-duration: 0.18s;
}
button.shield:active .tilt {
  transform: perspective(700px) rotateX(var(--rx)) rotateY(var(--ry)) scale(0.97);
  transition-duration: 0.08s;
}
.art {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}
.shield[data-locked] .art {
  filter: grayscale(1) brightness(0.62) contrast(0.9);
  opacity: 0.8;
}

.sheen {
  transform: translateX(-260px) skewX(-18deg);
}
.sparkles .spark,
.burst {
  opacity: 0;
}

/* The unlock, from the moment the drawing mounts with data-unlock. */
.art[data-unlock] .body {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: drop 0.62s cubic-bezier(0.2, 0.75, 0.3, 1) both;
}
.art[data-unlock] .wingUpper,
.art[data-unlock] .wingLower {
  transform-box: fill-box;
  transform-origin: 100% 50%;
  animation: unfold 0.55s cubic-bezier(0.25, 1.3, 0.4, 1) 0.32s both;
}
.art[data-unlock] .wingLower {
  animation-delay: 0.41s;
}
.art[data-unlock] .numeral {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: pop 0.48s cubic-bezier(0.3, 1.5, 0.5, 1) 0.62s both;
}
.art[data-unlock] .burst {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: burst 0.9s ease-out 0.78s both;
}
.art[data-unlock] .spark {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: spark 0.62s ease-out both;
}

@keyframes drop {
  0% {
    opacity: 0;
    transform: translateY(-46px) scale(1.12);
  }
  45% {
    opacity: 1;
  }
  62% {
    transform: translateY(5px) scale(0.97, 1.02);
  }
  80% {
    transform: translateY(-2px) scale(1.01, 0.99);
  }
  100% {
    transform: none;
  }
}
@keyframes unfold {
  0% {
    opacity: 0;
    transform: rotate(-38deg) scale(0.25, 0.6);
  }
  30% {
    opacity: 1;
  }
  100% {
    transform: none;
  }
}
@keyframes pop {
  0% {
    opacity: 0;
    transform: scale(0.2);
  }
  40% {
    opacity: 1;
  }
  65% {
    transform: scale(1.18);
  }
  100% {
    transform: none;
  }
}
@keyframes burst {
  0% {
    opacity: 0;
    transform: scale(0.5);
  }
  25% {
    opacity: 0.7;
  }
  100% {
    opacity: 0;
    transform: scale(1.25);
  }
}
@keyframes spark {
  0% {
    opacity: 0;
    transform: scale(0) rotate(-45deg);
  }
  40% {
    opacity: 1;
    transform: scale(1.15) rotate(0deg);
  }
  100% {
    opacity: 0;
    transform: scale(0.2) rotate(30deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .tilt,
  .shield[data-tilted] .tilt,
  button.shield:active .tilt {
    transform: none;
    transition: none;
  }
  .art[data-unlock] .body,
  .art[data-unlock] .wingUpper,
  .art[data-unlock] .wingLower,
  .art[data-unlock] .numeral,
  .art[data-unlock] .burst,
  .art[data-unlock] .spark {
    animation: none;
  }
}

.bar {
  display: flex;
  justify-content: center;
}
.replay {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 14px 0 11px;
  border: 0;
  border-radius: 999px;
  background: var(--button);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.15s;
}
.replay:hover {
  background: var(--button-hover);
}
.replay:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.replayIcon {
  width: 14px;
  height: 14px;
  flex: none;
}

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