Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Media scrub

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

media-scrub/media-scrub.tsx
Live · interactive

Shared media4 files

  • canyon-walk.mov
  • studio-reel.m4a
  • harbor-dusk.mov
  • alpine-dawn.mov

Arrow keys scrub a second at a time, with Shift five seconds. Enter or Space plays the clip.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/media-scrub of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/media-scrub.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

library-panel.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
filesreadonly MediaFile[]FILESThe 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.
teamreadonly Teammate[]TEAMThe people the library is shared with: an id, a name, a colour and an illustrated face, shown as a stack of avatars.
presencereadonly 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.
titlestring'Shared media'The library’s name, in the toolbar.
defaultStateMediaScrubState—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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

media-scrub.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.module.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);
scenes.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. */
waveform.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;
files.ts
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. */
avatars.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"
icons.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>
ghost-pointer.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: (
use-autoplay.ts
'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;
  • 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.