# Media scrub

> A shared media library where moving the pointer across a clip scrubs through it, while a teammate scrubs another.

The files of a shared media library, every picture drawn in code. Move the pointer across a video’s thumbnail and the clip scrubs under it: a thin playhead follows, the frame and the time follow the playhead, and leaving puts the poster back. Sounds light their waveform, a teammate’s avatar rides their own playhead, and every thumbnail is a slider you can scrub and play from the keyboard.

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

Category: Media.

## Anatomy

1. **Add**: An ink disc with a plus that calls `onAdd`: open an upload dialog there.
2. **Search**: Filters the files by name as you type; screen readers hear how many match once you pause.
3. **Shared with**: The people the library is shared with, overlapping. Someone scrubbing now gets a ring in their colour.
4. **Filter**: A native menu: all files, videos or audio.
5. **Thumbnail**: A real `role="slider"`: the pointer’s position across it is a time, marked by a thin playhead. Arrow keys move a second (Shift: five); Enter or a click plays it. Leaving puts the poster frame back.
6. **Time pill**: The clip’s length at rest, the time under the playhead while scrubbing, darker then, with a play mark while it plays.
7. **Teammate**: Someone else’s playhead in their colour, their face riding at its top and their name beside it.
8. **File row**: The kind’s icon, the name, cut with an ellipsis when long, and the … button that calls `onMore` with the file.

## When to use

- A shared library of clips and sounds, where people need to find the right take without opening each file.
- Thumbnails that should preview their content in place: across the picture, the pointer’s position is a time.
- Collaborative tools where it helps to see what a teammate is looking at now, down to the second.
- Grids of a few to a few dozen media files, each with a name, a length and a menu of actions.

## When not to use

- Watching or editing a clip in earnest. Open it in a real player, with a timeline and controls.
- Libraries of photos or documents, which have no time to scrub. Use a plain thumbnail grid.
- Hundreds of files at once: every visible card draws its own frames. Page or virtualise the list first.
- Touch-first screens where sideways swipes already mean something, like a carousel: the two gestures fight.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/media-scrub.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 { MediaScrub } from './media-scrub';
import { FILES, TEAM } from './files';

// Presence comes from your realtime layer: who is on which file, and where.
export function LibraryPanel() {
  return (
    <MediaScrub
      title="Launch film"
      files={FILES}
      team={TEAM}
      presence={[{ person: 'mira', file: 'harbor', time: 9 }]}
      autoplay={false}
    />
  );
}
```

## States

- **At rest**: Each video shows its poster frame and its length; each sound its quiet waveform.
- **Scrubbing**: Under the pointer: the playhead, the frame at that time and the time in a darker pill.
- **Playing**: A click, Enter or Space plays the clip in place. Held still here; live, the frames run on.
- **Scrubbing a sound**: The waveform lights up to the playhead, in violet.
- **Teammate here**: Set by `presence`: Mira’s playhead, face and name on her clip, and her ring in the stack.
- **Filtered**: Audio only: the grid keeps its columns, the count in the title stays the library’s.
- **No match**: A dashed box that names the search, in place of the grid.
- **Dark**: The interface follows the page, or `colorScheme`; the pictures and faces stay as they are.

## How it works

- Each video thumbnail is a landscape drawn in SVG as a pure function of time: a sky gradient, a sun or a moon on its arc, drifting clouds, and ridges that pan at different speeds. Any frame can be shown without a video file, and the ridges come from a seeded random generator, so the server and the browser draw the same first frame.
- Across a thumbnail, the pointer’s position is a time: a thin playhead follows it, the frame follows the playhead and the corner pill shows the time. Leaving the card brings the poster frame back.
- A sound lights its waveform up to the pointer. Its bars are one path drawn twice, the bright copy clipped at the playhead, so moving the playhead changes one rectangle.
- Each card keeps the time it shows, whether it plays, and what holds it there (the pointer, the keyboard’s focus or the demo); when the last of these lets go, the poster comes back. A clip plays on its own clock in an animation frame loop that runs only while something plays, and pauses off screen or in a hidden tab.
- Each thumbnail’s picture sits in a layer of its own, the size of the thumbnail, so drawing frames while scrubbing repaints only that small layer and never the page around it.
- Every interface colour is a light-dark() token on the root, so the library follows the page’s color-scheme, or the colorScheme prop; the drawn clips and the teammates’ faces are content and look the same in both schemes.

## API reference

- **files** (`readonly MediaFile[]`, default `FILES`): The files, in order. A video names one of the drawn scenes (canyon, harbor or alpine) and has a length, a poster time and an optional badge; a sound has a length, an optional badge and its waveform’s levels.
- **team** (`readonly Teammate[]`, default `TEAM`): The people the library is shared with: an id, a name, a colour and an illustrated face, shown as a stack of avatars.
- **presence** (`readonly Presence[]`): Where teammates are scrubbing now: a person’s id, a file’s id and a time in seconds. Their playhead carries their avatar and name, and the card shows their frame.
- **title** (`string`, default `'Shared media'`): The library’s name, in the toolbar.
- **defaultState** (`MediaScrubState`): Opens held still with a file scrubbed to a time (scrub: { file, time, playing? }), a search (query) or a filter (kind: 'all', 'video' or 'audio'). Turns the demo off.
- **onAdd** (`() => void`): Called when the + button is pressed: open an upload dialog there.
- **onMore** (`(file: MediaFile) => void`): Called with a file when its … button is pressed: open its menu there.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer scrubs and plays a clip and sweeps a waveform, and a teammate scrubs another clip.
- **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

- Each card is a group named by its file, and its thumbnail is a slider (“canyon-walk.mov, position”, “0:07 of 0:16”): the arrow keys move a second (Shift five), Page Up and Page Down five, Home and End go to the ends, and Enter or Space plays or pauses.
- Playing, pausing, the end of a clip and the files a search leaves are announced politely, and the first teammate to appear is announced once. The demo’s own actions stay silent.
- The search, the filter and the buttons are labelled controls, and the avatar stack is an image named “Shared with Mira, Theo and Ines”; the drawn scenes, badges and playheads are hidden from screen readers.
- On touch a sideways drag scrubs and a vertical swipe still scrolls the page. Under reduced motion there is no demo, scrubbing still follows the reader’s hand, and playback steps a second at a time.

## Source

### media-scrub/media-scrub.tsx

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

'use client';

import {
  memo,
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent,
} from 'react';
import { Avatar } from './avatars';
import { FILES, TEAM, type MediaFile, type Presence, type Teammate } from './files';
import { GhostPointer, runDemo, type Demo } from './ghost-pointer';
import { ChevronIcon, FilmIcon, MoreIcon, PlayIcon, PlusIcon, SearchIcon, WaveIcon } from './icons';
import { Scene } from './scenes';
import { useAutoplay } from './use-autoplay';
import { Waveform } from './waveform';
import styles from './media-scrub.module.css';

/*
 * Media scrub: the files of a shared media library, where hovering a clip scrubs through it.
 *
 * - A video's thumbnail is a drawn scene, a pure function of time (scenes.tsx), so any frame can
 *   be shown without a video file. Across the thumbnail, the pointer's position is a time: a thin
 *   playhead follows it, the frame follows the playhead and the corner pill shows the time.
 *   Leaving the card puts its poster frame back. A sound lights its waveform up to the pointer.
 * - Each thumbnail is a slider: the arrow keys scrub a second at a time (Shift: five), Home and
 *   End go to the ends, Enter or Space plays the clip in place. A click or a tap plays it too; on
 *   a touch screen a sideways drag scrubs, and a vertical one still scrolls the page.
 * - Teammates show where they are: their avatar rides at the top of their own playhead.
 * - A frame is drawn only when its time changes. An animation frame loop runs only while a clip
 *   plays or the demo moves, and only on screen: at rest, nothing runs.
 * - While on screen and untouched, a ghost pointer scrubs a clip, plays it and sweeps a
 *   waveform, while a teammate scrubs another clip on her own.
 * - Light or dark: the interface's colours are light-dark() tokens in the stylesheet, so it
 *   follows the page's color-scheme, or the one the colorScheme prop sets. The clips' pictures
 *   and the teammates' faces are content: the same at night.
 */

/** What holds a card away from its poster frame. */
type Hold = 'pointer' | 'keys' | 'demo';

/** A card being scrubbed or played: the time it shows, and what holds it there. */
type View = {
  /** The time shown, in seconds. */
  t: number;
  /** The time runs on its own. */
  playing: boolean;
} & Record<Hold, boolean>;

type Views = Readonly<Record<string, View>>;

/** The library's filter. */
type Kind = 'all' | MediaFile['kind'];

/**
 * A state to open in, for docs and tests: a file held at a time (as under the pointer; `playing`
 * draws its play mark, the frame held still), a search and a filter. Setting it turns the demo off.
 */
export type MediaScrubState = {
  scrub?: { file: string; time: number; playing?: boolean };
  query?: string;
  kind?: Kind;
};

/** The views a defaultState opens with: its file held by the pointer, with no clock to run. */
const seedViews = (files: readonly MediaFile[], state?: MediaScrubState): Views => {
  const file = files.find((one) => one.id === state?.scrub?.file);
  if (!file || !state?.scrub) return {};
  const t = Math.min(file.duration, Math.max(0, state.scrub.time));
  return { [file.id]: { ...idle(file), t, playing: !!state.scrub.playing, pointer: true } };
};

/** A teammate on a card, ready to draw; `leaving` while their playhead fades out. */
type Peer = { mate: Teammate; time: number; leaving?: boolean };

/** The handlers a card calls; the same ones serve the reader and the demo. */
type Actions = {
  hover: (file: MediaFile, share: number, by: 'pointer' | 'demo') => void;
  release: (file: MediaFile, by: Hold) => void;
  toggle: (file: MediaFile, byReader: boolean) => void;
  seek: (file: MediaFile, t: number) => void;
  focus: (file: MediaFile) => void;
  more: (file: MediaFile) => void;
  take: () => void;
};

/** 7 → "00:07", for the corner pill. */
const pillTime = (seconds: number) => {
  const s = Math.floor(seconds);
  return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};

/** 7 → "0:07", for screen readers. */
const spokenTime = (seconds: number) => {
  const s = Math.floor(seconds);
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};

/** The time a card shows at rest: a video's poster frame, the start of a sound. */
const restOf = (file: MediaFile) => (file.kind === 'video' ? file.poster : 0);

const idle = (file: MediaFile): View => ({
  t: restOf(file),
  playing: false,
  pointer: false,
  keys: false,
  demo: false,
});

/** Sets a card's view, or takes it away (back to the poster) once nothing holds it. */
function put(views: Views, id: string, view: View): Views {
  if (view.playing || view.pointer || view.keys || view.demo) return { ...views, [id]: view };
  if (!(id in views)) return views;
  const rest = { ...views };
  delete rest[id];
  return rest;
}

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

/** "Mira, Theo and Ines". */
const listOf = (names: string[]) =>
  names.length > 1
    ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
    : names.join('');

export function MediaScrub({
  files = FILES,
  team = TEAM,
  presence,
  title = 'Shared media',
  onAdd,
  onMore,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The files: a video plays a drawn scene in its thumbnail, a sound shows its waveform. */
  files?: readonly MediaFile[];
  /** The people the library is shared with, shown as a stack of avatars. */
  team?: readonly Teammate[];
  /** Where teammates are scrubbing now: a person's id, a file's id and a time in seconds. */
  presence?: readonly Presence[];
  /** The library's name, in the toolbar. */
  title?: string;
  /** Called when the + button is pressed: open an upload dialog there. */
  onAdd?: () => void;
  /** Called with a file when its … button is pressed: open its menu there. */
  onMore?: (file: MediaFile) => void;
  /** While on screen and untouched, a ghost pointer scrubs a clip and a teammate another. */
  autoplay?: boolean;
  /**
   * The interface's colours: light, dark, or 'system' for the reader's setting. Unset, it follows
   * the page's color-scheme (light if the page sets none). The clips' pictures stay as they are.
   */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens with a file held at a time, a search or a filter; turns the demo off. */
  defaultState?: MediaScrubState;
  className?: string;
}) {
  const { ref: root, playing: onStage, take } = useAutoplay<HTMLDivElement>(6000);
  const [views, setViews] = useState<Views>(() => seedViews(files, defaultState));
  const [query, setQuery] = useState(defaultState?.query ?? '');
  const [kind, setKind] = useState<Kind>(defaultState?.kind ?? 'all');
  /** The demo's teammate, while the demo plays. */
  const [demoPeer, setDemoPeer] = useState<(Presence & { leaving?: boolean }) | null>(null);
  const [message, setMessage] = useState('');
  const titleId = useId();
  const hintId = useId();
  const pointer = useRef<SVGSVGElement>(null);
  /** The latest views, for the handlers and loops that run between renders. */
  const live = useRef<Views>(views);
  /** Each playing clip's clock: the time it started from, when, and who started it. */
  const clocks = useRef(new Map<string, { from: number; at: number; byReader: boolean }>());
  const latestFiles = useRef(files);
  const latestMore = useRef(onMore);
  const filterTimer = useRef(0);
  const presenceTold = useRef(false);

  useEffect(() => {
    latestFiles.current = files;
    latestMore.current = onMore;
  });

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

  const update = useCallback((change: (views: Views) => Views) => {
    const next = change(live.current);
    if (next === live.current) return;
    live.current = next;
    setViews(next);
  }, []);

  const actions = useMemo<Actions>(() => {
    const hover: Actions['hover'] = (file, share, by) =>
      update((views) => {
        const now = views[file.id] ?? idle(file);
        // A clip that plays goes on playing: the pointer only holds it.
        const t = now.playing ? now.t : Math.min(1, Math.max(0, share)) * file.duration;
        return put(views, file.id, { ...now, t, [by]: true });
      });

    // The last hold to let go stops the clip and puts the poster back.
    const release: Actions['release'] = (file, by) => {
      const now = live.current[file.id];
      if (!now?.[by]) return;
      const next = { ...now, [by]: false };
      if (!next.pointer && !next.keys && !next.demo) {
        next.playing = false;
        clocks.current.delete(file.id);
      }
      update((views) => put(views, file.id, next));
    };

    const toggle: Actions['toggle'] = (file, byReader) => {
      const now = live.current[file.id];
      if (now?.playing) {
        clocks.current.delete(file.id);
        update((views) => put(views, file.id, { ...now, playing: false }));
        if (byReader) announce(`Paused at ${spokenTime(now.t)}.`);
        return;
      }
      // From where the playhead is; from the start when it is at the end, or nowhere.
      const from = now && now.t < file.duration - 0.05 ? now.t : 0;
      clocks.current.set(file.id, { from, at: performance.now(), byReader });
      update((views) =>
        put(views, file.id, { ...(views[file.id] ?? idle(file)), t: from, playing: true }),
      );
      if (byReader) announce(`Playing ${file.name}.`);
    };

    const seek: Actions['seek'] = (file, t) => {
      const time = Math.min(file.duration, Math.max(0, t));
      const clock = clocks.current.get(file.id);
      if (clock) clocks.current.set(file.id, { ...clock, from: time, at: performance.now() });
      update((views) =>
        put(views, file.id, { ...(views[file.id] ?? idle(file)), t: time, keys: true }),
      );
    };

    const focus: Actions['focus'] = (file) =>
      update((views) => put(views, file.id, { ...(views[file.id] ?? idle(file)), keys: true }));

    const more: Actions['more'] = (file) => latestMore.current?.(file);
    return { hover, release, toggle, seek, focus, more, take };
  }, [update, announce, take]);

  // Playback: while a clip plays, its time follows its clock, one animation frame at a time.
  // Under reduced motion the frames step a second at a time, without smoothing.
  const anyPlaying = Object.values(views).some((view) => view.playing);
  useEffect(() => {
    if (!anyPlaying) return;
    const reduced = reducedMotion();
    let frame = 0;
    let timer = 0;
    const tick = () => {
      const now = performance.now();
      const ended: MediaFile[] = [];
      update((views) => {
        let next = views;
        for (const [id, view] of Object.entries(views)) {
          const clock = clocks.current.get(id);
          const file = latestFiles.current.find((one) => one.id === id);
          if (!view.playing || !clock || !file) continue;
          const elapsed = (now - clock.at) / 1000;
          const t = clock.from + (reduced ? Math.floor(elapsed) : elapsed);
          if (t >= file.duration) {
            ended.push(file);
            next = put(next, id, { ...view, t: file.duration, playing: false });
          } else if (t !== view.t) next = put(next, id, { ...view, t });
        }
        return next;
      });
      for (const file of ended) {
        if (clocks.current.get(file.id)?.byReader) announce(`End of ${file.name}.`);
        clocks.current.delete(file.id);
      }
      schedule();
    };
    const schedule = () => {
      if (reduced) timer = window.setTimeout(tick, 250);
      else frame = requestAnimationFrame(tick);
    };
    schedule();
    return () => {
      cancelAnimationFrame(frame);
      window.clearTimeout(timer);
    };
  }, [anyPlaying, update, announce]);

  // Off screen or in a hidden tab, clips pause where they are.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const pauseAll = () => {
      const running = new Set(clocks.current.keys());
      clocks.current.clear();
      update((views) => {
        let next = views;
        for (const [id, view] of Object.entries(views)) {
          // A clip only plays with a clock; one opened playing by defaultState is held still.
          if (view.playing && running.has(id)) next = put(next, id, { ...view, playing: false });
        }
        return next;
      });
    };
    const observer = new IntersectionObserver(([entry]) => {
      if (!entry.isIntersecting) pauseAll();
    });
    observer.observe(element);
    const onVisibility = () => {
      if (document.hidden) pauseAll();
    };
    document.addEventListener('visibilitychange', onVisibility);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', onVisibility);
    };
  }, [root, update]);

  // Teammates: the demo's, or the ones given. The first to show up is announced, once.
  const demo = autoplay && !defaultState && onStage;
  const peers: readonly Presence[] = demoPeer ? [demoPeer] : demo ? [] : (presence ?? []);
  const someone = peers.length > 0;
  const tellPresence = useEffectEvent(() => {
    const [first] = peers;
    const mate = team.find((one) => one.id === first?.person);
    const file = files.find((one) => one.id === first?.file);
    if (presenceTold.current || !mate || !file) return;
    presenceTold.current = true;
    announce(`${mate.name} is looking at ${file.name}.`);
  });
  useEffect(() => {
    if (someone) tellPresence();
  }, [someone]);
  const peerOn = new Map<string, Peer>();
  for (const one of peers) {
    const mate = team.find((person) => person.id === one.person);
    if (mate) peerOn.set(one.file, { mate, time: one.time, leaving: demoPeer?.leaving });
  }

  // The demo: the ghost pointer scrubs the first video and plays it, then sweeps the first
  // sound; meanwhile the first teammate scrubs another video.
  const readLatest = useEffectEvent(() => ({ files, team }));
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const { files: list, team: people } = readLatest();
    const videos = list.filter((file) => file.kind === 'video');
    const sound = list.find((file) => file.kind === 'audio');
    const mate = people[0];
    const thumbOf = (file: MediaFile) =>
      stage.querySelector(`[data-file="${CSS.escape(file.id)}"]`);

    const ghost = async ({ wait, moveTo, sweep, press, leave }: Demo) => {
      for (let round = 0; ; round += 1) {
        await wait(round ? 1600 : 800);
        const clip = videos[0];
        if (clip) {
          const thumb = thumbOf(clip);
          const scrub = (share: number) => actions.hover(clip, share, 'demo');
          await moveTo(thumb, { x: 0.12, y: 0.62 });
          await sweep(thumb, 0.12, 0.88, 2600, scrub);
          await wait(320);
          await sweep(thumb, 0.88, 0.36, 1300, scrub);
          await wait(420);
          await press(() => actions.toggle(clip, false));
          await wait(2900);
          actions.release(clip, 'demo');
        }
        if (sound) {
          const thumb = thumbOf(sound);
          const scrub = (share: number) => actions.hover(sound, share, 'demo');
          await moveTo(thumb, { x: 0.1, y: 0.6 });
          await sweep(thumb, 0.1, 0.8, 2300, scrub);
          await wait(650);
          actions.release(sound, 'demo');
        }
        await leave();
      }
    };

    const teammate = async ({ wait, tween }: Demo) => {
      const others = videos.slice(1);
      if (!mate || !others.length) return;
      for (let round = 0; ; round += 1) {
        await wait(round ? 2400 : 1400);
        const clip = others[round % others.length];
        let share = 0.22;
        const at = (s: number) =>
          setDemoPeer({ person: mate.id, file: clip.id, time: s * clip.duration });
        at(share);
        for (const [to, ms, pause] of [
          [0.66, 2500, 900],
          [0.46, 1300, 600],
          [0.9, 2300, 1200],
        ]) {
          const from = share;
          await tween(ms, (k) => at(from + (to - from) * k));
          share = to;
          await wait(pause);
        }
        setDemoPeer((now) => now && { ...now, leaving: true });
        await wait(360);
        setDemoPeer(null);
      }
    };

    const stop = runDemo(stage, arrow, (steps) => Promise.all([ghost(steps), teammate(steps)]));
    return () => {
      stop();
      setDemoPeer(null);
      // Whatever the demo held goes back to its poster (unless the reader holds it now).
      for (const file of list) actions.release(file, 'demo');
    };
  }, [demo, root, actions]);

  const needle = query.trim().toLowerCase();
  const matching = (text: string, by: Kind) =>
    files.filter(
      (file) => (by === 'all' || file.kind === by) && file.name.toLowerCase().includes(text),
    );
  const shown = matching(needle, kind);

  /** Tells screen readers how many files match, once the reader pauses. */
  const tellCount = (text: string, by: Kind) => {
    window.clearTimeout(filterTimer.current);
    filterTimer.current = window.setTimeout(() => {
      const count = matching(text.trim().toLowerCase(), by).length;
      announce(count ? `${count} ${count === 1 ? 'file' : 'files'}.` : 'No files match.');
    }, 600);
  };
  useEffect(() => () => window.clearTimeout(filterTimer.current), []);

  const present = new Set(peers.map((one) => one.person));

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <section className={styles.panel} aria-labelledby={titleId}>
        <header className={styles.toolbar} data-part="toolbar">
          <button
            type="button"
            className={styles.add}
            data-part="add"
            aria-label="Add files"
            onClick={onAdd}
          >
            <PlusIcon />
          </button>
          <p className={styles.heading} data-part="heading">
            <span id={titleId} className={styles.title}>
              {title}
            </span>
            <span className={styles.count}>
              {files.length} {files.length === 1 ? 'file' : 'files'}
            </span>
          </p>
          <label className={styles.search} data-part="search">
            <SearchIcon className={styles.searchIcon} />
            <input
              type="search"
              className={styles.searchInput}
              placeholder="Search"
              aria-label="Search files"
              autoComplete="off"
              spellCheck={false}
              value={query}
              onChange={(event) => {
                setQuery(event.target.value);
                tellCount(event.target.value, kind);
              }}
            />
          </label>
          <div
            className={styles.people}
            data-part="people"
            role="img"
            aria-label={`Shared with ${listOf(team.map((person) => person.name))}`}
          >
            {team.map((person) => (
              <span
                key={person.id}
                className={styles.person}
                data-present={present.has(person.id) || undefined}
                style={{ '--color': person.color } as CSSProperties}
              >
                <Avatar face={person.face} className={styles.face} />
              </span>
            ))}
          </div>
          <label className={styles.filter} data-part="filter">
            <select
              className={styles.filterSelect}
              aria-label="Show"
              value={kind}
              onChange={(event) => {
                const next = event.target.value as Kind;
                setKind(next);
                tellCount(query, next);
              }}
            >
              <option value="all">All files</option>
              <option value="video">Videos</option>
              <option value="audio">Audio</option>
            </select>
            <ChevronIcon className={styles.filterIcon} />
          </label>
        </header>
        {shown.length ? (
          <ul className={styles.grid} data-part="grid" aria-label="Files">
            {shown.map((file) => (
              <li key={file.id} className={styles.item}>
                <Card
                  file={file}
                  view={views[file.id]}
                  peer={peerOn.get(file.id)}
                  hintId={hintId}
                  actions={actions}
                />
              </li>
            ))}
          </ul>
        ) : (
          <p className={styles.empty} data-part="empty">
            {needle ? `No files match “${query.trim()}”.` : 'No files of this kind.'}
          </p>
        )}
      </section>
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
      <p id={hintId} className={styles.hidden}>
        Arrow keys scrub a second at a time, with Shift five seconds. Enter or Space plays the clip.
      </p>
    </div>
  );
}

/** A file: its thumbnail, a slider that scrubs, then its name and its menu button. */
const Card = memo(function Card({
  file,
  view,
  peer,
  hintId,
  actions,
}: {
  file: MediaFile;
  view: View | undefined;
  peer: Peer | undefined;
  hintId: string;
  actions: Actions;
}) {
  // SVG ids from React's, without the characters a url(#…) reference would trip on.
  const id = `ms${useId().replace(/[^\w-]/g, '')}`;
  /** A finger on the thumbnail: where it landed, and whether it is scrubbing. */
  const touch = useRef<{ id: number; x: number; y: number; scrubbing: boolean } | null>(null);
  /** A scrub by touch ends in a click: that one is not a press. */
  const skipClick = useRef(false);

  const value = view ? view.t : restOf(file);
  /** The frame on show: the reader's (or the demo's), else a teammate's, else the poster. */
  const shown = view ? view.t : peer ? peer.time : restOf(file);
  const lit = view || peer ? shown / file.duration : 0;

  const shareAt = (event: PointerEvent<HTMLElement>) => {
    const box = event.currentTarget.getBoundingClientRect();
    return (event.clientX - box.left) / box.width;
  };

  return (
    <div className={styles.card} data-part="card" role="group" aria-labelledby={`${id}-name`}>
      <div
        className={styles.thumb}
        data-part="thumb"
        data-file={file.id}
        data-kind={file.kind}
        data-active={view ? '' : undefined}
        role="slider"
        tabIndex={0}
        aria-label={`${file.name}, position`}
        aria-valuemin={0}
        aria-valuemax={file.duration}
        aria-valuenow={Math.floor(value)}
        aria-valuetext={`${spokenTime(value)} of ${spokenTime(file.duration)}`}
        aria-describedby={hintId}
        style={
          {
            '--head': view ? Math.round((view.t / file.duration) * 1e4) / 1e4 : 0,
            '--peer': peer ? Math.round((peer.time / file.duration) * 1e4) / 1e4 : 0,
            '--color': peer?.mate.color,
          } as CSSProperties
        }
        onPointerMove={(event) => {
          if (event.pointerType === 'touch') {
            const start = touch.current;
            if (start?.id !== event.pointerId) return;
            if (!start.scrubbing) {
              const dx = event.clientX - start.x;
              const dy = event.clientY - start.y;
              // A vertical swipe scrolls the page; only a sideways drag scrubs.
              if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy)) return;
              start.scrubbing = true;
              event.currentTarget.setPointerCapture(event.pointerId);
            }
          }
          actions.take();
          actions.hover(file, shareAt(event), 'pointer');
        }}
        onPointerLeave={(event) => {
          if (event.pointerType !== 'touch') actions.release(file, 'pointer');
        }}
        onPointerDown={(event) => {
          if (event.pointerType === 'touch' && event.isPrimary) {
            touch.current = {
              id: event.pointerId,
              x: event.clientX,
              y: event.clientY,
              scrubbing: false,
            };
          }
        }}
        onPointerUp={(event) => {
          const start = touch.current;
          if (start?.id !== event.pointerId) return;
          touch.current = null;
          if (start.scrubbing) {
            skipClick.current = true;
            actions.release(file, 'pointer');
          }
        }}
        onPointerCancel={() => {
          if (touch.current?.scrubbing) actions.release(file, 'pointer');
          touch.current = null;
        }}
        onClick={() => {
          if (skipClick.current) skipClick.current = false;
          else actions.toggle(file, true);
        }}
        onFocus={(event) => {
          if (event.currentTarget.matches(':focus-visible')) actions.focus(file);
        }}
        onBlur={() => actions.release(file, 'keys')}
        onKeyDown={(event) => {
          const from = Math.round(value);
          const big = event.shiftKey ? 5 : 1;
          let to: number;
          switch (event.key) {
            case 'ArrowRight':
            case 'ArrowUp':
              to = from + big;
              break;
            case 'ArrowLeft':
            case 'ArrowDown':
              to = from - big;
              break;
            case 'PageUp':
              to = from + 5;
              break;
            case 'PageDown':
              to = from - 5;
              break;
            case 'Home':
              to = 0;
              break;
            case 'End':
              to = file.duration;
              break;
            case 'Enter':
            case ' ':
              event.preventDefault();
              actions.toggle(file, true);
              return;
            default:
              return;
          }
          event.preventDefault();
          actions.seek(file, to);
        }}
      >
        <div className={styles.screen}>
          {file.kind === 'video' ? (
            <Scene
              className={styles.scene}
              scene={file.scene}
              time={shown}
              duration={file.duration}
              id={id}
            />
          ) : (
            <Waveform className={styles.wave} levels={file.levels} share={lit} id={id} />
          )}
          {peer && (
            <span
              className={styles.peerHead}
              data-part="peer-playhead"
              data-leaving={peer.leaving || undefined}
            />
          )}
          <span className={styles.head} data-part="playhead" />
        </div>
        {file.badge && (
          <span className={styles.badge} data-part="badge" aria-hidden="true">
            {file.badge}
          </span>
        )}
        <span
          className={styles.pill}
          data-part="pill"
          data-tone={view ? 'own' : peer ? 'peer' : undefined}
          aria-hidden="true"
        >
          {view?.playing && <PlayIcon />}
          {pillTime(view || peer ? shown : file.duration)}
        </span>
        {peer && (
          <span
            className={styles.flag}
            data-leaving={peer.leaving || undefined}
            data-flip={peer.time / file.duration > 0.62 || undefined}
            aria-hidden="true"
          >
            <span className={styles.flagFace} data-part="peer-face">
              <Avatar face={peer.mate.face} />
            </span>
            <span className={styles.flagName} data-part="peer-name">
              {peer.mate.name}
            </span>
          </span>
        )}
      </div>
      <div className={styles.meta} data-part="meta">
        {file.kind === 'video' ? (
          <FilmIcon className={styles.kindIcon} />
        ) : (
          <WaveIcon className={styles.kindIcon} />
        )}
        <span id={`${id}-name`} className={styles.name}>
          {file.name}
        </span>
        <button
          type="button"
          className={styles.more}
          data-part="more"
          aria-label={`More actions for ${file.name}`}
          onClick={() => actions.more(file)}
        >
          <MoreIcon />
        </button>
      </div>
    </div>
  );
});
```

### media-scrub/media-scrub.module.css

```css
/* Media scrub (media-scrub.tsx): a stage with the library's panel in its middle. A card's
   data-active is set while it is scrubbed or played; --head, --peer and --color, set on each
   thumbnail, place and paint the playheads, as shares of the thumbnail's width (so no token below
   may take those names).

   The interface's colours are the tokens below, each 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). The clips' pictures (scenes.tsx) and the
   teammates' faces and colours are content: the same in both schemes. */

.root {
  /* The stage, and the panel raised on it: a step lighter at night. */
  --surface: #e8e8eb;
  --surface-2: #fff;
  /* The stage's dots; the fields (search, filter), the … button's hover; a picture not drawn yet. */
  --dots: rgb(0 0 0 / 0.05);
  --wash: #f2f2f5;
  --placeholder: #d9d9de;
  /* The words and the icons. */
  --ink: #1d1d1f;
  --ink-muted: #86868b;
  /* Hairlines: the stage's edge, the panel's, round each picture, and the empty state's dashes. */
  --line: rgb(0 0 0 / 0.06);
  --panel-edge: rgb(0 0 0 / 0.04);
  --frame: rgb(0 0 0 / 0.07);
  --dashes: rgb(0 0 0 / 0.1);
  /* The + button: an ink disc with the plus the other way round, and its hover. */
  --accent: #1d1d1f;
  --accent-ink: #fff;
  --accent-hover: #3a3a3c;
  /* The panel's lift (its contact shadow, then its drop), and a teammate's flag's. */
  --shadow: rgb(0 0 0 / 0.18);
  --shadow-near: rgb(0 0 0 / 0.04);
  --flag-shadow: rgb(0 0 0 / 0.25);
  /* Over the pictures: the badges and the time pill (darker while scrubbed), and their words. */
  --badge: rgb(0 0 0 / 0.32);
  --pill: rgb(0 0 0 / 0.5);
  --pill-own: rgb(0 0 0 / 0.68);
  --chip-ink: #fff;
  /* The playhead over a picture, white on a soft dark edge; a teammate's, in their colour, keeps
     a light edge. */
  --playhead: #fff;
  --playhead-edge: rgb(0 0 0 / 0.16);
  --playhead-glow: rgb(0 0 0 / 0.28);
  --peer-edge: rgb(255 255 255 / 0.5);
  /* A sound's card: violet, pale by day and deep at night, with its bars, playhead and badge. */
  --audio-from: #f1ecff;
  --audio-to: #e2d9ff;
  --audio-glow: rgb(255 255 255 / 0.7);
  --violet: #6a4fe0;
  --violet-dim: rgb(110 86 220 / 0.3);
  --violet-ink: #5a3fd0;
  --violet-wash: rgb(106 79 224 / 0.14);
  /* The focus ring: the system's blue, legible on both. */
  --focus: #0a84ff;
  /* The demo's pointer (ghost-pointer.tsx): dark on a white edge by day, white on a dark one at
     night. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font:
    400 14px/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(#e8e8eb, #0f0f11);
    --surface-2: light-dark(#fff, #1c1c1e);
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --wash: light-dark(#f2f2f5, #2c2c2e);
    --placeholder: light-dark(#d9d9de, #2c2c2e);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --ink-muted: light-dark(#86868b, #98989d);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --panel-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.08));
    --frame: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.08));
    --dashes: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.14));
    --accent: light-dark(#1d1d1f, #f5f5f7);
    --accent-ink: light-dark(#fff, #1d1d1f);
    --accent-hover: light-dark(#3a3a3c, #d1d1d6);
    --shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.32));
    --flag-shadow: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5));
    --badge: light-dark(rgb(0 0 0 / 0.32), rgb(0 0 0 / 0.42));
    --pill: light-dark(rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.58));
    --pill-own: light-dark(rgb(0 0 0 / 0.68), rgb(0 0 0 / 0.74));
    --playhead-edge: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.24));
    --playhead-glow: light-dark(rgb(0 0 0 / 0.28), rgb(0 0 0 / 0.4));
    --audio-from: light-dark(#f1ecff, #2a2341);
    --audio-to: light-dark(#e2d9ff, #1f1a32);
    --audio-glow: light-dark(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.06));
    --violet: light-dark(#6a4fe0, #a796ff);
    --violet-dim: light-dark(rgb(110 86 220 / 0.3), rgb(167 150 255 / 0.36));
    --violet-ink: light-dark(#5a3fd0, #c9bdff);
    --violet-wash: light-dark(rgb(106 79 224 / 0.14), rgb(167 150 255 / 0.18));
    --pointer: light-dark(#1c1c1e, #f5f5f7);
    --pointer-edge: light-dark(#fff, #1c1c1e);
    --pointer-shadow: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root :where(button, input, select) {
  font: inherit;
  color: inherit;
}

.panel {
  box-sizing: border-box;
  width: min(680px, calc(100% - 64px));
  margin: 36px 0;
  padding: 14px 16px 18px;
  border-radius: 18px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--panel-edge),
    0 1px 2px var(--shadow-near),
    0 16px 40px -14px var(--shadow);
}

/* The toolbar: one row, or two in a narrow panel. */
.toolbar {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) minmax(120px, 200px) auto auto;
  grid-template-areas: 'add heading search people filter';
  align-items: center;
  gap: 10px;
}
.add {
  grid-area: add;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
  cursor: pointer;
  transition:
    transform 0.15s ease,
    background-color 0.2s ease;
}
.add svg {
  width: 16px;
  height: 16px;
}
.add:active {
  transform: scale(0.93);
}
.heading {
  grid-area: heading;
  display: flex;
  flex-direction: column;
  min-width: 0;
  margin: 0;
}
.title {
  overflow: hidden;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.count {
  font-size: 12px;
  color: var(--ink-muted);
}

.search {
  grid-area: search;
  position: relative;
  display: flex;
  align-items: center;
  height: 34px;
  border-radius: 10px;
  background: var(--wash);
  color: var(--ink-muted);
  transition:
    background-color 0.2s ease,
    box-shadow 0.2s ease;
}
.searchIcon {
  position: absolute;
  left: 10px;
  width: 15px;
  height: 15px;
  pointer-events: none;
}
.searchInput {
  box-sizing: border-box;
  width: 100%;
  min-width: 0;
  height: 100%;
  margin: 0;
  padding: 0 8px 0 31px;
  border: 0;
  border-radius: inherit;
  outline: none;
  background: none;
  appearance: none;
}
.searchInput::placeholder {
  color: var(--ink-muted);
}
.search:focus-within {
  background: var(--surface-2);
  box-shadow: 0 0 0 2px var(--focus);
}

.people {
  grid-area: people;
  display: flex;
  padding-left: 7px;
}
.person {
  position: relative;
  width: 26px;
  height: 26px;
  margin-left: -7px;
  border-radius: 50%;
  box-shadow: 0 0 0 2px var(--surface-2);
  transition: box-shadow 0.25s ease;
}
.person[data-present] {
  z-index: 1;
  box-shadow:
    0 0 0 2px var(--surface-2),
    0 0 0 3.5px var(--color);
}
.face {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
}

.filter {
  grid-area: filter;
  position: relative;
  display: flex;
  align-items: center;
  height: 34px;
  border-radius: 10px;
  background: var(--wash);
}
.filterSelect {
  height: 100%;
  margin: 0;
  padding: 0 30px 0 12px;
  border: 0;
  border-radius: inherit;
  outline: none;
  background: none;
  font-weight: 500;
  appearance: none;
  cursor: pointer;
}
.filterIcon {
  position: absolute;
  right: 9px;
  width: 14px;
  height: 14px;
  color: var(--ink-muted);
  pointer-events: none;
}
.filter:has(.filterSelect:focus-visible) {
  box-shadow: 0 0 0 2px var(--focus);
}

/* The files. */
.grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 20px 16px;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.item {
  min-width: 0;
}
.card {
  display: flex;
  flex-direction: column;
  gap: 7px;
}
.empty {
  margin: 18px 0 0;
  padding: 40px 16px;
  border: 1.5px dashed var(--dashes);
  border-radius: 12px;
  color: var(--ink-muted);
  text-align: center;
}

/* The thumbnail: the slider. Its screen clips the picture and the playheads; the badges and a
   teammate's flag sit over it. */
.thumb {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: 12px;
  outline: none;
  cursor: pointer;
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.thumb:focus-visible {
  box-shadow:
    0 0 0 2px var(--surface-2),
    0 0 0 4px var(--focus);
}
/* A layer of its own, the size of the thumbnail: a frame drawn while scrubbing repaints only this
   small layer, never the page's big tiles around it. */
.screen {
  position: absolute;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  border-radius: inherit;
  background: var(--placeholder);
  will-change: transform;
}
/* A hairline over the picture's edge. */
.screen::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--frame);
  pointer-events: none;
}
.scene {
  display: block;
  width: 100%;
  height: 100%;
}

/* A sound: its waveform on a violet card, pale by day and deep at night. */
.thumb[data-kind='audio'] .screen {
  background:
    radial-gradient(120% 90% at 85% 0%, var(--audio-glow), transparent 60%),
    linear-gradient(160deg, var(--audio-from), var(--audio-to));
}
.wave {
  position: absolute;
  top: 24%;
  left: 9%;
  width: 82%;
  height: 52%;
}
.thumb[data-kind='audio'] .badge {
  background: var(--violet-wash);
  color: var(--violet-ink);
}
.barsDim,
.barsLit {
  fill: none;
  stroke-width: 1.9;
  stroke-linecap: round;
}
.barsDim {
  stroke: var(--violet-dim);
}
.barsLit {
  stroke: var(--violet);
}

/* The playheads: full-size boxes moved by a share of their own width, a line at their left edge.
   Moving one is a transform; the screen clips what overhangs. */
.head,
.peerHead {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.head {
  transform: translateX(calc(var(--head) * 100%));
  opacity: 0;
}
.peerHead {
  transform: translateX(calc(var(--peer) * 100%));
  transition: opacity 0.3s ease;
}
.head::before,
.peerHead::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: -1px;
  width: 2px;
  border-radius: 1px;
}
.head::before {
  background: var(--playhead);
  box-shadow:
    0 0 0 0.5px var(--playhead-edge),
    0 0 8px var(--playhead-glow);
}
.peerHead::before {
  background: var(--color);
  box-shadow: 0 0 0 0.5px var(--peer-edge);
}
.thumb[data-kind='audio'] .head::before {
  background: var(--violet);
  box-shadow: none;
}
.thumb[data-active] .head {
  opacity: 1;
}

.badge,
.pill {
  position: absolute;
  display: inline-flex;
  align-items: center;
  border-radius: 6px;
  color: var(--chip-ink);
  line-height: 1;
  pointer-events: none;
}
.badge {
  top: 8px;
  left: 8px;
  padding: 3px 5px;
  background: var(--badge);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.05em;
}
.pill {
  right: 8px;
  bottom: 8px;
  gap: 3px;
  padding: 4px 6px;
  border-radius: 999px;
  background: var(--pill);
  font-size: 10.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  transition: background-color 0.2s ease;
}
.pill svg {
  width: 8px;
  height: 8px;
}
.pill[data-tone='own'] {
  background: var(--pill-own);
}
.pill[data-tone='peer'] {
  background: var(--color);
}

/* A teammate's flag: their face at the top of their playhead, and their name beside it. */
.flag {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  transform: translateX(calc(var(--peer) * 100%));
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.flag[data-leaving],
.peerHead[data-leaving] {
  opacity: 0;
}
.flagFace {
  position: absolute;
  top: -12px;
  left: -12px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--surface-2);
  box-shadow:
    0 0 0 2px var(--color),
    0 0 0 3.5px var(--surface-2),
    0 3px 8px var(--flag-shadow);
  animation: arrive 0.4s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.flagFace svg {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
}
.flagName {
  position: absolute;
  top: -8px;
  left: 16px;
  padding: 3px 7px;
  border-radius: 999px;
  background: var(--color);
  color: var(--chip-ink);
  font-size: 10.5px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  animation: arrive 0.4s 0.05s cubic-bezier(0.3, 1.4, 0.5, 1) backwards;
}
.flag[data-flip] .flagName {
  right: calc(100% + 16px);
  left: auto;
}
@keyframes arrive {
  from {
    opacity: 0;
    scale: 0.5;
  }
}

/* Under the thumbnail: the kind, the name, the menu. */
.meta {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  padding-left: 2px;
}
.kindIcon {
  flex: none;
  width: 15px;
  height: 15px;
  color: var(--ink-muted);
}
.name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-size: 13px;
  font-weight: 500;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.more {
  flex: none;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin-right: -4px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--ink-muted);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.more svg {
  width: 16px;
  height: 16px;
}
.add:focus-visible,
.more:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
@media (hover: hover) {
  .add:hover {
    background: var(--accent-hover);
  }
  .more:hover {
    background: var(--wash);
    color: var(--ink);
  }
}

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

/* Phones: inputs at 16px, so focusing one does not zoom the page. */
@media (hover: none) and (pointer: coarse) {
  .searchInput,
  .filterSelect {
    font-size: 16px;
  }
}

@container (max-width: 620px) {
  .panel {
    width: calc(100% - 28px);
    margin: 14px 0;
    padding: 12px 12px 14px;
    border-radius: 16px;
  }
  .toolbar {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas:
      'add heading people'
      'search search filter';
    row-gap: 12px;
  }
  .grid {
    gap: 14px 10px;
    margin-top: 14px;
  }
  .thumb {
    border-radius: 10px;
  }
  .badge {
    top: 6px;
    left: 6px;
    padding: 2px 4px;
    font-size: 8.5px;
  }
  .pill {
    right: 6px;
    bottom: 6px;
    padding: 3px 5px;
    font-size: 9.5px;
  }
  .name {
    font-size: 12px;
  }
  .kindIcon {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .flagFace,
  .flagName {
    animation: none;
  }
}
```

### media-scrub/scenes.tsx

```tsx
/*
 * The clips, drawn in code: three landscapes, each a pure function of time, so any frame of a
 * "video" can be shown without a video file. A frame is an SVG of a dozen shapes: a sky gradient,
 * a sun or a moon on its arc, drifting clouds, ridges that pan at different speeds (nearer ones
 * faster, so the camera seems to move) and a few details with a life of their own.
 *
 * The ridges' outlines are made once, from a seeded random generator, so the server and the
 * browser draw the same first frame. From one frame to the next only transforms, colours and a
 * few short paths change.
 */

import type { ReactNode } from 'react';

export type SceneName = 'canyon' | 'harbor' | 'alpine';

/** The frame, in SVG units: 16:9. */
const W = 160;
const H = 90;

type Point = [number, number];
type Rgb = readonly [number, number, number];
/** Colours along the clip: [share of the clip, colour]. */
type Keys = readonly (readonly [number, Rgb])[];
/** What a scene draws a frame from: its ids' prefix, the time in seconds, the share of the clip. */
type Frame = { id: string; t: number; u: number };

/** Colours along the clip, written "0 #79aee6, 0.5 #5d9be0": a share of the clip, its colour. */
const keys = (text: string): Keys =>
  text.split(', ').map((pair) => {
    const [u, hex] = pair.split(' ');
    const channel = (at: number) => parseInt(hex.slice(at, at + 2), 16);
    return [Number(u), [channel(1), channel(3), channel(5)]];
  });

/** Points written "x y, x y, …", in the frame's units. */
const points = (text: string) =>
  text.split(', ').map((pair): Point => {
    const [x, y] = pair.split(' ').map(Number);
    return [x, y];
  });

const clamp = (n: number, min = 0, max = 1) => Math.min(max, Math.max(min, n));
const mix = (a: number, b: number, k: number) => a + (b - a) * k;
/** 0 before `from`, 1 after `to`, eased in between. */
const smooth = (from: number, to: number, x: number) => {
  const k = clamp((x - from) / (to - from));
  return k * k * (3 - 2 * k);
};
/** Rounded for the markup: short attributes, and the same on the server and in the browser. */
const r2 = (n: number) => Math.round(n * 100) / 100;

/** The colour at `u` (0 to 1) along the keys. */
function colorAt(k: Keys, u: number) {
  let i = 0;
  while (i < k.length - 2 && u > k[i + 1][0]) i += 1;
  const [u0, a] = k[i];
  const [u1, b] = k[i + 1];
  const s = clamp((u - u0) / (u1 - u0));
  return `rgb(${Math.round(mix(a[0], b[0], s))},${Math.round(mix(a[1], b[1], s))},${Math.round(mix(a[2], b[2], s))})`;
}

/** A seeded generator of numbers from 0 to 1 (mulberry32): integer maths, the same everywhere. */
function random(seed: number) {
  let state = seed;
  return () => {
    state = (state + 0x6d2b79f5) | 0;
    let t = Math.imul(state ^ (state >>> 15), 1 | state);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** A ridge's outline: along its top, then down past the bottom of the frame. */
function outline(line: Point[]) {
  const top = line.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`);
  const first = line[0][0].toFixed(1);
  const last = line[line.length - 1][0].toFixed(1);
  return `${top.join('')}L${last} ${H + 2}L${first} ${H + 2}Z`;
}

/** The height of a line of points at `x`. */
function heightAt(line: Point[], x: number) {
  let i = 1;
  while (i < line.length - 1 && line[i][0] < x) i += 1;
  const [x0, y0] = line[i - 1];
  const [x1, y1] = line[i];
  return mix(y0, y1, clamp((x - x0) / (x1 - x0 || 1)));
}

/** Rolling ground: a random height every `step`, eased between. */
function hills(seed: number, to: number, step: number, base: number, amp: number) {
  const rand = random(seed);
  const anchors: Point[] = [];
  for (let x = -6; x <= to + step; x += step) anchors.push([x, base - rand() * amp]);
  const line: Point[] = [];
  anchors.slice(1).forEach(([x1, y1], i) => {
    const [x0, y0] = anchors[i];
    for (let j = 0; j < 4; j += 1) {
      const s = j / 4;
      line.push([mix(x0, x1, s), mix(y0, y1, s * s * (3 - 2 * s))]);
    }
  });
  line.push(anchors[anchors.length - 1]);
  return line;
}

/** Mesas: flat tops between steep sides, over a valley floor. */
function mesas(seed: number, to: number, floor: number, low: number, high: number) {
  const rand = random(seed);
  const line: Point[] = [[-6, floor - rand() * 3]];
  let x = -6;
  while (x < to) {
    const top = floor - mix(low, high, rand());
    const rise = 1.2 + rand() * 2.4;
    const width = 7 + rand() * 20;
    const fall = 1.5 + rand() * 3;
    const gap = 3 + rand() * 12;
    line.push(
      [x + rise, top + 0.6],
      [x + rise + 0.8, top],
      [x + rise + width, top + (rand() - 0.5) * 0.8],
      [x + rise + width + fall, floor - rand() * 4],
      [x + rise + width + fall + gap, floor - rand() * 3],
    );
    x += rise + width + fall + gap;
  }
  return line;
}

/** A rocky line through `anchors`: midpoints displaced up or down, less and less at each level. */
function rocky(seed: number, anchors: Point[], spread: number) {
  const rand = random(seed);
  let line = anchors;
  let amount = spread;
  for (let level = 0; level < 4; level += 1) {
    const next: Point[] = [line[0]];
    for (let i = 1; i < line.length; i += 1) {
      const [x0, y0] = line[i - 1];
      const [x1, y1] = line[i];
      next.push([mix(x0, x1, 0.4 + rand() * 0.2), (y0 + y1) / 2 + (rand() - 0.5) * amount]);
      next.push(line[i]);
    }
    line = next;
    amount *= 0.55;
  }
  return line;
}

/** A forest edge: pointed trees standing on gentle ground. */
function pines(seed: number, to: number, base: number, amp: number) {
  const rand = random(seed);
  const ground = hills(seed + 7, to, 26, base, 5);
  const line: Point[] = [[-6, heightAt(ground, -6)]];
  let x = -6;
  while (x < to) {
    const width = 2.4 + rand() * 2.2;
    const tall = 4 + rand() * amp;
    const g = heightAt(ground, x + width / 2);
    line.push([x + width * 0.5, g - tall], [x + width, g - rand() * 1.5]);
    x += width;
  }
  return line;
}

/** Tiny stars, as one path: dots in the upper sky. */
function stars(seed: number, count: number, top: number) {
  const rand = random(seed);
  let d = '';
  for (let i = 0; i < count; i += 1) {
    const x = rand() * W;
    const y = rand() * top;
    const r = 0.25 + rand() * 0.35;
    d += `M${(x - r).toFixed(2)} ${y.toFixed(2)}a${r.toFixed(2)} ${r.toFixed(2)} 0 1 1 ${(2 * r).toFixed(2)} 0a${r.toFixed(2)} ${r.toFixed(2)} 0 1 1 ${(-2 * r).toFixed(2)} 0`;
  }
  return d;
}

/**
 * A cloud 28 units wide, its bottom left corner at 0, 0: puffs over a flat base, all wound the same
 * way so that, filled, they make one shape.
 */
const CLOUD =
  'M3.5 0a3.5 3.5 0 0 1 0-7h21a3.5 3.5 0 0 1 0 7ZM5 -4.2a4.4 4.4 0 1 1 8.8 0a4.4 4.4 0 1 1 -8.8 0ZM10.5 -5a6 6 0 1 1 12 0a6 6 0 1 1 -12 0ZM18.6 -3.6a3.9 3.9 0 1 1 7.8 0a3.9 3.9 0 1 1 -7.8 0Z';

type Cloud = { x: number; y: number; size: number; speed: number };

/** Clouds drift on their own and with the camera, and come back round the other side. */
function Clouds({
  clouds,
  t,
  pan,
  fill,
  opacity,
}: {
  clouds: Cloud[];
  t: number;
  pan: number;
  fill: string;
  opacity: number;
}) {
  return (
    <g fill={fill} opacity={r2(opacity)}>
      {clouds.map((cloud, i) => {
        const span = W + 40 * cloud.size;
        const x = ((((cloud.x + cloud.speed * t - pan) % span) + span) % span) - 30 * cloud.size;
        return (
          <path
            key={i}
            d={CLOUD}
            transform={`translate(${r2(x)} ${cloud.y}) scale(${cloud.size})`}
          />
        );
      })}
    </g>
  );
}

/** A sky from `top` to `bottom`, down to the horizon. */
function Sky({
  id,
  top,
  bottom,
  horizon,
}: {
  id: string;
  top: string;
  bottom: string;
  horizon: number;
}) {
  return (
    <>
      <linearGradient
        id={`${id}-sky`}
        x1="0"
        y1="0"
        x2="0"
        y2={horizon}
        gradientUnits="userSpaceOnUse"
      >
        <stop offset="0" stopColor={top} />
        <stop offset="1" stopColor={bottom} />
      </linearGradient>
      <rect width={W} height={H} fill={`url(#${id}-sky)`} />
    </>
  );
}

/** A sun or a moon, with a soft glow made of a radial gradient (no filter). */
function Orb({
  id,
  x,
  y,
  r,
  color,
  glow,
  opacity = 1,
}: {
  id: string;
  x: number;
  y: number;
  r: number;
  color: string;
  glow: number;
  opacity?: number;
}) {
  return (
    <g opacity={r2(opacity)}>
      <radialGradient id={`${id}-glow`}>
        <stop offset="0" stopColor={color} stopOpacity="0.6" />
        <stop offset="1" stopColor={color} stopOpacity="0" />
      </radialGradient>
      <circle cx={r2(x)} cy={r2(y)} r={glow} fill={`url(#${id}-glow)`} />
      <circle cx={r2(x)} cy={r2(y)} r={r} fill={color} />
    </g>
  );
}

/** A layer that pans with the camera: `pan` units over the whole clip. */
const panned = (pan: number, u: number) => `translate(${r2(-pan * u)} 0)`;

/* -- Canyon walk: a hiker crosses a desert canyon from morning to golden hour. -------------- */

const CANYON = {
  far: outline(mesas(11, W + 14, 58, 6, 15)),
  mid: outline(mesas(23, W + 30, 68, 5, 19)),
  near: outline(
    rocky(
      5,
      points(
        '-6 12, 6 15, 12 26, 15 46, 18 66, 26 82, 64 82, 71 72, 73 56, 75 47, 80 46, 82 56, 85 72, 92 82, 158 82, 163 62, 167 40, 172 29, 190 27, 216 30',
      ),
      4,
    ),
  ),
  ground: hills(41, W + 56, 30, 80, 5),
  skyTop: keys('0 #79aee6, 0.5 #5d9be0, 1 #8a83c4'),
  skyBottom: keys('0 #f8dfc4, 0.5 #e8eef0, 1 #ffc887'),
  sun: keys('0 #fff6dc, 0.6 #fffaf0, 1 #ffb867'),
  cloud: keys('0 #ffffff, 0.6 #ffffff, 1 #ffe0c8'),
  farFill: keys('0 #d8b4ab, 0.5 #d5aaa0, 1 #cf8f86'),
  midFill: keys('0 #c98762, 0.5 #c27850, 1 #b65a3f'),
  nearFill: keys('0 #81402c, 0.5 #7a3826, 1 #5c241b'),
  groundFill: keys('0 #e3b386, 0.5 #e0a874, 1 #d88d5c'),
  trail: keys('0 #f2d3b0, 1 #f0bf8d'),
  ink: keys('0 #3a1f1a, 1 #2a1411'),
  clouds: [
    { x: 8, y: 17, size: 0.75, speed: 1.6 },
    { x: 74, y: 27, size: 0.5, speed: 1.1 },
    { x: 118, y: 12, size: 0.95, speed: 2 },
  ],
};
const CANYON_TRAIL = CANYON.ground.map(([x, y]): Point => [x, y + 3.4]);
const CANYON_TRAIL_PATH = CANYON_TRAIL.map(
  ([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`,
).join('');
const CANYON_GROUND = outline(CANYON.ground);

function Canyon({ id, t, u }: Frame) {
  const sunX = mix(24, 126, u);
  const sunY = 50 - Math.sin(Math.PI * mix(0.16, 0.84, u)) * 36;
  // The camera follows the hiker: the ground pans 44 units, the hiker walks 56.
  const walker = 50 + 56 * u;
  const step = Math.sin(t * 2 * Math.PI * 1.1);
  const ink = colorAt(CANYON.ink, u);
  return (
    <>
      <Sky
        id={id}
        top={colorAt(CANYON.skyTop, u)}
        bottom={colorAt(CANYON.skyBottom, u)}
        horizon={62}
      />
      <Orb id={id} x={sunX} y={sunY} r={5.5} glow={22} color={colorAt(CANYON.sun, u)} />
      <Clouds
        clouds={CANYON.clouds}
        t={t}
        pan={6 * u}
        fill={colorAt(CANYON.cloud, u)}
        opacity={0.9}
      />
      <path d={CANYON.far} fill={colorAt(CANYON.farFill, u)} transform={panned(8, u)} />
      <path d={CANYON.mid} fill={colorAt(CANYON.midFill, u)} transform={panned(20, u)} />
      <path d={CANYON.near} fill={colorAt(CANYON.nearFill, u)} transform={panned(44, u)} />
      <g transform={panned(44, u)}>
        <path d={CANYON_GROUND} fill={colorAt(CANYON.groundFill, u)} />
        <path
          d={CANYON_TRAIL_PATH}
          fill="none"
          stroke={colorAt(CANYON.trail, u)}
          strokeWidth="1.6"
          strokeLinecap="round"
        />
        <g transform={`translate(${r2(walker)} ${r2(heightAt(CANYON_TRAIL, walker) + 0.6)})`}>
          <path
            d={`M0 -3.2L${r2(step * 1.3)} 0M0 -3.2L${r2(-step * 1.3)} 0`}
            stroke={ink}
            strokeWidth="0.95"
            strokeLinecap="round"
          />
          <path d="M-1 -7.2h2l.4 4.2h-2.8Z" fill={ink} />
          <path d="M-2.3 -7h1.5v3.4h-1.7Z" fill="#c9553a" />
          <circle cx="0.1" cy="-8.4" r="1.15" fill={ink} />
        </g>
      </g>
    </>
  );
}

/* -- Harbour at dusk: the sun sets into the sea, a boat crosses, the lighthouse wakes. ------- */

const HARBOR = {
  far: outline(hills(3, W + 12, 22, 60, 9)),
  head: outline(points('-6 53, 5 51, 14 53.5, 24 58, 32 61, 40 62.5')),
  cape: outline(points('116 62.5, 122 59, 128 55, 134 53, 144 53.4, 156 52, 174 51.5')),
  stars: stars(9, 46, 44),
  skyTop: keys('0 #6478cf, 0.45 #3b3f8e, 1 #0f1638'),
  skyBottom: keys('0 #ffb48c, 0.45 #f17d86, 1 #3a3570'),
  sun: keys('0 #fff1c9, 0.4 #ffbe73, 0.6 #ff8a5c'),
  farFill: keys('0 #8a78b4, 0.5 #5d4f8e, 1 #241f4b'),
  landFill: keys('0 #4c3d76, 0.5 #33295c, 1 #120f2a'),
  seaTop: keys('0 #f2a088, 0.45 #c86b86, 1 #2a2a5c'),
  seaBottom: keys('0 #3d3f86, 1 #080b20'),
  hull: keys('0 #2c2a4e, 1 #0b0c1f'),
  sail: keys('0 #fbefe7, 0.5 #e7c3c4, 1 #7d7aa3'),
};

function Harbor({ id, t, u }: Frame) {
  const horizon = 62;
  const sunX = mix(78, 92, u);
  const sunY = mix(30, 80, u);
  const sun = colorAt(HARBOR.sun, u);
  const sunUp = 1 - smooth(0.45, 0.6, u);
  const night = smooth(0.42, 0.85, u);
  const moonX = mix(36, 44, u);
  const moonY = mix(34, 20, u);
  // The lighthouse turns on at dusk; its beam sweeps round, seen from the side.
  const lamp: Point = [137, 45.4];
  const turn = Math.cos(t * 1.7);
  const boatX = mix(12, 106, u) - 6 * u;
  const boatY = 66.5 + Math.sin(t * 2.3) * 0.45;
  const tilt = Math.sin(t * 1.6) * 3;
  // The sun's path on the water: dashes that shimmer as the waves move.
  let glint = '';
  for (let i = 0; i < 7; i += 1) {
    const y = horizon + 2 + i * 3.4;
    const half = (7 - i) * 0.9 + 1.2;
    const sway = Math.sin(t * 2.4 + i * 1.7) * 1.4;
    glint += `M${r2(sunX - half + sway)} ${r2(y)}h${r2(half * 2)}`;
  }
  let waves = '';
  for (let i = 0; i < 5; i += 1) {
    const y = horizon + 5 + i * 5.2;
    const shift = (((t * (2 + i * 0.6) + i * 13) % 24) + 24) % 24;
    for (let x = -24 + shift; x < W; x += 24) waves += `M${r2(x)} ${r2(y)}h${6 + i * 1.5}`;
  }
  return (
    <>
      <Sky
        id={id}
        top={colorAt(HARBOR.skyTop, u)}
        bottom={colorAt(HARBOR.skyBottom, u)}
        horizon={horizon}
      />
      <path d={HARBOR.stars} fill="#fff" opacity={r2(night)} />
      <Orb
        id={`${id}-moon`}
        x={moonX}
        y={moonY}
        r={3.6}
        glow={14}
        color="#f6f1df"
        opacity={smooth(0.5, 0.8, u)}
      />
      <Orb id={id} x={sunX} y={sunY} r={6} glow={26} color={sun} opacity={0.35 + 0.65 * sunUp} />
      <path d={HARBOR.far} fill={colorAt(HARBOR.farFill, u)} transform={panned(4, u)} />
      <linearGradient
        id={`${id}-sea`}
        x1="0"
        y1={horizon}
        x2="0"
        y2={H}
        gradientUnits="userSpaceOnUse"
      >
        <stop offset="0" stopColor={colorAt(HARBOR.seaTop, u)} />
        <stop offset="1" stopColor={colorAt(HARBOR.seaBottom, u)} />
      </linearGradient>
      <rect y={horizon} width={W} height={H - horizon} fill={`url(#${id}-sea)`} />
      <path
        d={glint}
        stroke={sun}
        strokeWidth="1.1"
        strokeLinecap="round"
        opacity={r2(0.85 * sunUp)}
      />
      <path d={waves} stroke="#fff" strokeWidth="0.5" strokeLinecap="round" opacity="0.18" />
      <g transform={panned(6, u)}>
        <path d={HARBOR.head} fill={colorAt(HARBOR.landFill, u)} />
        <path d={HARBOR.cape} fill={colorAt(HARBOR.landFill, u)} />
        <path d="M135.6 53.6l.5-7.4h1.8l.5 7.4Z" fill={colorAt(HARBOR.sail, u * 0.7)} />
        <path d="M135.4 46.2h3.2v-1.2a1.6 1.6 0 0 0 -3.2 0Z" fill={colorAt(HARBOR.hull, u)} />
        <linearGradient id={`${id}-beam`}>
          <stop offset="0" stopColor="#fff6c8" stopOpacity="0.7" />
          <stop offset="1" stopColor="#fff6c8" stopOpacity="0" />
        </linearGradient>
        <path
          d="M0 0L60 -4V4Z"
          fill={`url(#${id}-beam)`}
          transform={`translate(${lamp[0]} ${lamp[1]}) scale(${r2(turn)} 1)`}
          opacity={r2(smooth(0.38, 0.62, u) * (0.3 + 0.7 * Math.abs(turn)))}
        />
        <circle
          cx={lamp[0]}
          cy={lamp[1]}
          r="1.1"
          fill="#fff3b8"
          opacity={r2(0.25 + 0.75 * smooth(0.35, 0.55, u))}
        />
      </g>
      <g transform={`translate(${r2(boatX)} ${r2(boatY)}) rotate(${r2(tilt)})`}>
        <path d="M-6 0h12l-2.4 2.6h-7.6Z" fill={colorAt(HARBOR.hull, u)} />
        <path d="M-0.4 -0.4v-11.6l6 10.8Z" fill={colorAt(HARBOR.sail, u)} />
        <path d="M-1.2 -0.4v-8.6l-4.4 7.8Z" fill={colorAt(HARBOR.sail, u)} opacity="0.8" />
        <circle cx="4.2" cy="-1.4" r="0.7" fill="#ffd27a" opacity={r2(smooth(0.55, 0.75, u))} />
      </g>
    </>
  );
}

/* -- Alpine dawn: the sun rises behind the peaks, the mist lifts, birds cross. ------------- */

const ALPINE = {
  far: outline(
    rocky(
      17,
      points('-6 40, 18 29, 34 41, 52 22, 70 37, 86 32, 104 45, 124 25, 146 39, 166 30, 172 37'),
      7,
    ),
  ),
  mid: outline(rocky(29, points('-6 55, 20 49, 44 58, 66 48, 90 57, 112 51, 140 59, 186 53'), 5)),
  near: outline(pines(13, W + 46, 80, 9)),
  stars: stars(21, 36, 40),
  skyTop: keys('0 #2b2d5e, 0.5 #4f6ec2, 1 #6fa6e6'),
  skyBottom: keys('0 #e8a0ab, 0.5 #ffd3b2, 1 #e6f2ff'),
  sun: keys('0 #ffcf9c, 0.5 #fff0d6, 1 #fffbec'),
  farFill: keys('0 #8d8fc4, 0.5 #c9c9ec, 1 #eef1ff'),
  midFill: keys('0 #55599a, 0.5 #6a7fb6, 1 #5d8faa'),
  nearFill: keys('0 #1d2547, 0.5 #1d3646, 1 #1d4a42'),
  mist: keys('0 #f4d9e6, 1 #ffffff'),
};

function Alpine({ id, t, u }: Frame) {
  const sunX = mix(96, 106, u);
  const sunY = mix(62, 18, u);
  let birds = '';
  for (let i = 0; i < 3; i += 1) {
    const x = mix(18, 132, u) + i * 5.5 - (i === 1 ? 2 : 0);
    const y = mix(40, 27, u) + (i === 1 ? -2.6 : i * 1.2);
    const flap = 1.2 + Math.sin(t * 7 + i * 2) * 1.1;
    birds += `M${r2(x - 2)} ${r2(y - flap)}L${r2(x)} ${r2(y)}L${r2(x + 2)} ${r2(y - flap)}`;
  }
  return (
    <>
      <Sky
        id={id}
        top={colorAt(ALPINE.skyTop, u)}
        bottom={colorAt(ALPINE.skyBottom, u)}
        horizon={64}
      />
      <path d={ALPINE.stars} fill="#fff" opacity={r2(1 - smooth(0, 0.45, u))} />
      <Orb id={id} x={sunX} y={sunY} r={5.5} glow={24} color={colorAt(ALPINE.sun, u)} />
      <path d={ALPINE.far} fill={colorAt(ALPINE.farFill, u)} transform={panned(6, u)} />
      <linearGradient
        id={`${id}-mist`}
        x1="0"
        y1="56"
        x2="0"
        y2="80"
        gradientUnits="userSpaceOnUse"
      >
        <stop offset="0" stopColor={colorAt(ALPINE.mist, u)} stopOpacity="0" />
        <stop offset="0.55" stopColor={colorAt(ALPINE.mist, u)} stopOpacity="0.75" />
        <stop offset="1" stopColor={colorAt(ALPINE.mist, u)} stopOpacity="0" />
      </linearGradient>
      <path d={ALPINE.mid} fill={colorAt(ALPINE.midFill, u)} transform={panned(16, u)} />
      <rect
        y="56"
        width={W}
        height="24"
        fill={`url(#${id}-mist)`}
        opacity={r2(1 - 0.6 * smooth(0.2, 1, u))}
      />
      <path
        d={birds}
        fill="none"
        stroke={colorAt(ALPINE.nearFill, u)}
        strokeWidth="0.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path d={ALPINE.near} fill={colorAt(ALPINE.nearFill, u)} transform={panned(40, u)} />
    </>
  );
}

const SCENES: Record<SceneName, (frame: Frame) => ReactNode> = {
  canyon: Canyon,
  harbor: Harbor,
  alpine: Alpine,
};

/**
 * One frame of a drawn clip: `scene` at `time` seconds of a clip `duration` seconds long. `id`
 * keeps its gradients apart from other frames' on the page.
 */
export function Scene({
  scene,
  time,
  duration,
  id,
  className,
}: {
  scene: SceneName;
  time: number;
  duration: number;
  id: string;
  className?: string;
}) {
  const Draw = SCENES[scene];
  return (
    <svg
      className={className}
      viewBox={`0 0 ${W} ${H}`}
      preserveAspectRatio="xMidYMid slice"
      aria-hidden="true"
    >
      <Draw id={id} t={time} u={clamp(time / duration)} />
    </svg>
  );
}
```

### media-scrub/waveform.tsx

```tsx
/*
 * An audio file's thumbnail: its waveform as a row of rounded bars. The bars are one path, drawn
 * twice: dim, then bright through a clip that ends at the playhead, so moving the playhead only
 * changes the width of one rectangle.
 */

import styles from './media-scrub.module.css';

/** Each bar's slot, in SVG units; the bars are a little thinner, with round ends. */
const SLOT = 3;
const HEIGHT = 40;

export function Waveform({
  levels,
  share,
  id,
  className,
}: {
  /** How loud the sound is along its length, one bar each, from 0 to 1. */
  levels: readonly number[];
  /** How much of it is lit, from 0 to 1. */
  share: number;
  /** Keeps its clip apart from other waveforms' on the page. */
  id: string;
  className?: string;
}) {
  const width = levels.length * SLOT;
  const bars = levels
    .map((level, i) => {
      const half = Math.max(1, level * (HEIGHT / 2 - 2));
      return `M${i * SLOT + SLOT / 2} ${(HEIGHT / 2 - half).toFixed(2)}V${(HEIGHT / 2 + half).toFixed(2)}`;
    })
    .join('');
  return (
    <svg className={className} viewBox={`0 0 ${width} ${HEIGHT}`} aria-hidden="true">
      <clipPath id={`${id}-lit`}>
        <rect width={Math.round(share * width * 100) / 100} height={HEIGHT} />
      </clipPath>
      <path className={styles.barsDim} d={bars} />
      <path className={styles.barsLit} d={bars} clipPath={`url(#${id}-lit)`} />
    </svg>
  );
}
```

### media-scrub/files.ts

```tsx
import type { Face } from './avatars';
import type { SceneName } from './scenes';

/*
 * The library's sample content: four made-up files and the three people it is shared with. A
 * video names one of the drawn scenes (scenes.tsx); an audio file carries its waveform.
 */

/** A clip whose frames are drawn in code. */
export type VideoFile = {
  kind: 'video';
  id: string;
  name: string;
  /** Its length, in seconds. */
  duration: number;
  /** A badge on the thumbnail, such as "4K". */
  badge?: string;
  /** The drawn scene that plays in the thumbnail. */
  scene: SceneName;
  /** The frame the thumbnail shows at rest, in seconds. */
  poster: number;
};

/** A sound, shown as its waveform. */
export type AudioFile = {
  kind: 'audio';
  id: string;
  name: string;
  /** Its length, in seconds. */
  duration: number;
  /** A badge on the thumbnail, such as "Stereo". */
  badge?: string;
  /** How loud it is along its length, one level per bar, from 0 to 1. */
  levels: readonly number[];
};

export type MediaFile = VideoFile | AudioFile;

/** Someone the library is shared with. */
export type Teammate = { id: string; name: string; color: string; face: Face };

/** Where a teammate is scrubbing: their id, the file's id and the time, in seconds. */
export type Presence = { person: string; file: string; time: number };

export const FILES: MediaFile[] = [
  {
    kind: 'video',
    id: 'canyon',
    name: 'canyon-walk.mov',
    duration: 16,
    badge: '4K',
    scene: 'canyon',
    poster: 6,
  },
  {
    kind: 'audio',
    id: 'reel',
    name: 'studio-reel.m4a',
    duration: 42,
    badge: 'Stereo',
    levels: [
      0.13, 0.22, 0.25, 0.25, 0.35, 0.29, 0.38, 0.36, 0.42, 0.49, 0.42, 0.54, 0.43, 0.79, 0.54,
      0.64, 0.82, 0.73, 0.87, 0.6, 0.95, 0.64, 0.8, 0.78, 0.49, 0.73, 0.65, 0.85, 0.72, 0.23, 0.31,
      0.21, 0.29, 0.64, 0.6, 0.75, 0.6, 0.82, 0.65, 0.79, 0.79, 0.6, 0.94, 0.66, 0.74, 0.77, 0.72,
      0.92, 0.53, 0.6, 0.54, 0.38, 0.36, 0.25, 0.19, 0.09,
    ],
  },
  {
    kind: 'video',
    id: 'harbor',
    name: 'harbor-dusk.mov',
    duration: 24,
    badge: '4K',
    scene: 'harbor',
    poster: 9,
  },
  {
    kind: 'video',
    id: 'alpine',
    name: 'alpine-dawn.mov',
    duration: 12,
    badge: 'HD',
    scene: 'alpine',
    poster: 6,
  },
];

export const TEAM: Teammate[] = [
  {
    id: 'mira',
    name: 'Mira',
    color: '#ff6b3d',
    face: { bg: '#ffd9c7', skin: '#c98d6b', hair: '#2b1d1a', shirt: '#ff6b3d', hairStyle: 'bun' },
  },
  {
    id: 'theo',
    name: 'Theo',
    color: '#3478f6',
    face: {
      bg: '#d6e4ff',
      skin: '#f0c4a4',
      hair: '#8a5a32',
      shirt: '#3478f6',
      hairStyle: 'short',
      glasses: true,
    },
  },
  {
    id: 'ines',
    name: 'Ines',
    color: '#12a37a',
    face: { bg: '#cdeee2', skin: '#8d5b43', hair: '#1c1412', shirt: '#12a37a', hairStyle: 'curly' },
  },
];
```

### media-scrub/avatars.tsx

```tsx
/*
 * Small illustrated teammates: no photos, so the demo needs no image files and shows no real
 * faces. Use an <img> in their place for real people.
 */

export type Face = {
  /** The backdrop, behind the head. */
  bg: string;
  skin: string;
  hair: string;
  shirt: string;
  hairStyle: 'short' | 'long' | 'bun' | 'curly';
  glasses?: boolean;
};

/** A face in a circle, drawn in a 24 × 24 box. Decorative: name the person around it. */
export function Avatar({ face, className }: { face: Face; className?: string }) {
  const { bg, skin, hair, shirt, hairStyle, glasses } = face;
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <circle cx="12" cy="12" r="12" fill={bg} />
      {hairStyle === 'long' && (
        <path
          d="M6.6 11.8c-.6-4.7 1.9-7.6 5.4-7.6s6 2.9 5.4 7.6c.3 2.5.8 4.2 1.7 5.5-2.7.8-5 .4-7.1-.6-2.1 1-4.4 1.4-7.1.6.9-1.3 1.4-3 1.7-5.5Z"
          fill={hair}
        />
      )}
      <path d="M3.6 24c.8-4.6 4.1-7.1 8.4-7.1s7.6 2.5 8.4 7.1Z" fill={shirt} />
      <rect x="10.2" y="13.6" width="3.6" height="3.8" rx="1.6" fill={skin} />
      <circle cx="12" cy="10.6" r="4.6" fill={skin} />
      {hairStyle !== 'curly' && (
        <path
          d="M7.2 10.4c-.3-3.8 2-5.9 4.9-5.9 3 0 5.1 2.1 4.8 5.9-.6-1.7-1.9-2.8-3.7-3-1.5.9-3.7 1.5-6 3Z"
          fill={hair}
        />
      )}
      {hairStyle === 'bun' && <circle cx="12" cy="4.3" r="2.1" fill={hair} />}
      {hairStyle === 'curly' && (
        <g fill={hair}>
          <circle cx="8.2" cy="8.4" r="2.2" />
          <circle cx="10.4" cy="6.4" r="2.3" />
          <circle cx="13.4" cy="6.3" r="2.3" />
          <circle cx="15.8" cy="8.3" r="2.2" />
          <circle cx="7.4" cy="10.8" r="1.6" />
          <circle cx="16.6" cy="10.8" r="1.6" />
        </g>
      )}
      {glasses && (
        <g fill="none" stroke="#1d1d1f" strokeWidth=".75">
          <circle cx="10.1" cy="11" r="1.3" />
          <circle cx="13.9" cy="11" r="1.3" />
          <path d="M11.4 11h1.2" />
        </g>
      )}
    </svg>
  );
}
```

### media-scrub/icons.tsx

```tsx
/* The panel's icons, drawn on a 16 × 16 grid in the current colour. All decorative. */

type IconProps = { className?: string };

export function PlusIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M8 3.2v9.6M3.2 8h9.6"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
      />
    </svg>
  );
}

export function SearchIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="7.1" cy="7.1" r="4.4" fill="none" stroke="currentColor" strokeWidth="1.5" />
      <path d="m10.4 10.4 3.1 3.1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

export function ChevronIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="m4.5 6.5 3.5 3.5 3.5-3.5"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function MoreIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" fill="currentColor">
      <circle cx="3.5" cy="8" r="1.35" />
      <circle cx="8" cy="8" r="1.35" />
      <circle cx="12.5" cy="8" r="1.35" />
    </svg>
  );
}

/** A strip of film: the videos' mark. */
export function FilmIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <rect
        x="2.2"
        y="3"
        width="11.6"
        height="10"
        rx="2"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.4"
      />
      <path d="m6.8 6 3.2 2-3.2 2Z" fill="currentColor" />
    </svg>
  );
}

/** A waveform: the sounds' mark. */
export function WaveIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M2.5 7v2M5.25 4.5v7M8 6v4M10.75 3v10M13.5 6.5v3"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

export function PlayIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M5 3.4v9.2a.6.6 0 0 0 .9.5l7.2-4.6a.6.6 0 0 0 0-1L5.9 2.9a.6.6 0 0 0-.9.5Z"
        fill="currentColor"
      />
    </svg>
  );
}
```

### media-scrub/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a card, sweep it across a thumbnail while the card scrubs, press.
 * Steps can run side by side (the teammate scrubs while the pointer does). Stopping the demo (the
 * reader takes over, or it scrolls away) ends every step in progress at once: their promises
 * reject, which ends the scripts right there, and the pointer fades out.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /**
   * Sweeps the pointer across `target`, from one share of its width to another, at the height it
   * is at; `onShare` gets the share under the pointer on every frame.
   */
  sweep: (
    target: Element | null | undefined,
    from: number,
    to: number,
    ms: number,
    onShare: (share: number) => void,
  ) => Promise<void>;
  /** Calls `onFrame` with the eased progress, 0 to 1, on every frame for `ms`. */
  tween: (ms: number, onFrame: (k: number) => void) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /** Moves the pointer off, down and to the right, and fades it out. */
  leave: () => Promise<void>;
};

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

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Slow at both ends, as a hand moves. */
const easeInOut = (k: number) => (k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2);

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<unknown>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const frames = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.92 };

  /** 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);
      }),
    );

  /** 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 frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    return { x: (x - frame.left) / scale, y: (y - frame.top) / scale };
  };

  const tween: Demo['tween'] = (ms, onFrame) =>
    step(
      new Promise<void>((resolve) => {
        const start = performance.now();
        const tick = (now: number) => {
          frames.delete(frame);
          const k = Math.min(1, Math.max(0, (now - start) / ms));
          onFrame(easeInOut(k));
          if (k < 1) {
            frame = requestAnimationFrame(tick);
            frames.add(frame);
          } else resolve();
        };
        let frame = requestAnimationFrame(tick);
        frames.add(frame);
      }),
    );

  const fly = (to: Point, ms: number) => {
    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.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          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,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: ms || Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    pointer.style.opacity = '1';
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return fly(toStage(box.left + box.width * share.x, box.top + box.height * share.y), 0);
  };

  const sweep: Demo['sweep'] = (target, from, to, ms, onShare) => {
    if (!target) return step(Promise.resolve());
    flight?.cancel();
    const y = at.y;
    return tween(ms, (k) => {
      // Measured on every frame: the page may scroll under the demo.
      const box = target.getBoundingClientRect();
      const share = from + (to - from) * k;
      at = { x: toStage(box.left + box.width * share, 0).x, y };
      pointer.style.transform = place(at);
      onShare(share);
    });
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const leave: Demo['leave'] = async () => {
    const flying = fly({ x: at.x + 40, y: at.y + 56 }, 520);
    pointer.style.opacity = '0';
    await flying;
  };

  pointer.style.transform = place(at);
  script({ wait, moveTo, sweep, tween, press, leave }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

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

/** Its colours are the stage's tokens (media-scrub.module.css): dark by day, white at night. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo stays silent. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### media-scrub/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 };
}
```