Try it

9 × 9

Play, then drag the playhead. Lift away from the rail for finer seeking.

Illustrative waveform and playback clock. No audio plays.

The interaction. The playhead changes its lights at precision thresholds. Seeking stays continuous and never changes playback intent.

One shared language. Permanent dots, selective light handoffs and clean outlined shells. Chalk, Carbon and Ember carry the family across sizes and themes.

Make it yours. The preview and installation use the same source. Pointer, touch and keyboard interactions share the same state model, with system reduced motion respected throughout.

Install

Add it with the shadcn CLI, or copy the source manually.

Terminal
npx shadcn@latest add https://seanportfol.io/r/dot-matrix-playback.json
Prefer a namespace? Register the registry once.
Terminal
npx shadcn@latest registry add @seanlynch=https://seanportfol.io/r/{name}.json
Terminal
npx shadcn@latest add @seanlynch/dot-matrix-playback

Usage

This is the working preview example. Save it in a separate file after installing the component. Recording, media clocks and requests are illustrative: connect their controlled state and callbacks to your application.

components/examples/playback-demo.tsx
'use client';

import { useState, useEffect, useRef, useCallback } from 'react';
import { MatrixPlayback } from '@/components/dot-matrix/playback-entry';

// Illustrative samples and clock. Connect these values to your audio engine.
const waveform = Array.from({ length: 96 }, (_, i) => .18 + .72 * Math.abs(Math.sin(i * .37) * Math.cos(i * .13)));

function usePreviewClock(running: boolean, advance: (seconds: number) => void, pause: () => void) {
  const frame = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!running) return;
    const stopWhenHidden = () => { if (document.hidden) pause(); };
    const observer = new IntersectionObserver(([entry]) => { if (!entry.isIntersecting) pause(); });
    if (frame.current) observer.observe(frame.current);
    document.addEventListener('visibilitychange', stopWhenHidden);
    window.addEventListener('blur', pause);
    let last = performance.now();
    const timer = window.setInterval(() => {
      const now = performance.now();
      if (!document.hidden) advance((now - last) / 1000);
      last = now;
    }, 100);
    return () => {
      clearInterval(timer); observer.disconnect();
      document.removeEventListener('visibilitychange', stopWhenHidden);
      window.removeEventListener('blur', pause);
    };
  }, [running, advance, pause]);
  return frame;
}

export default function PlaybackExample() {
  const [playing, setPlaying] = useState(false);
  const [elapsed, setElapsed] = useState(24);
  const [scrubbing, setScrubbing] = useState(false);
  const advance = useCallback((dt: number) => setElapsed(t => Math.min(144, t + dt)), []);
  const pause = useCallback(() => setPlaying(false), []);
  const frame = usePreviewClock(playing && !scrubbing && elapsed < 144, advance, pause);
  return <div ref={frame} style={{ width: '100%' }}>
    <MatrixPlayback playing={playing && elapsed < 144} elapsed={elapsed} duration={144}
      title="Coastal recording" subtitle="Notes from the coast" waveform={waveform}
      onToggle={() => { if (elapsed >= 144) { setElapsed(0); setPlaying(true); } else setPlaying(p => !p); }}
      onSeek={setElapsed} onScrubChange={setScrubbing} />
  </div>;
}

API Reference

Props on Playback. Consult the included TypeScript source for supported native attributes.

PropTypeDefault
playingControlled playback intent.boolean–
elapsed / durationCurrent position and total length in seconds.number–
title / subtitleReadable media labels.string–
onToggleConnect play and pause to your media engine.() => void–
onSeekCommit a seek or restore the initial position on cancellation.(seconds: number) => void–
onScrubChangeSuspend the media clock while the user scrubs.(scrubbing: boolean) => void–
waveformOptional normalized amplitudes; absent samples produce a flat rail.readonly number[]–
gridFixed cornerless lattice; glyphs are authored for each resolution.5 | 7 | 9 | 119
size48 / 56 / 64px primary controls with matching insets.'compact' | 'regular' | 'large''regular'
glyphSizingThe accepted optical scale is 22 / 24 / 26px.'optical' | 'footprint' | 'dot-weight''optical'
slowSlows animation only, never gestures, operation time or media clocks.booleanfalse
reducedMotionImmediate state clarity without indirect travel or activity loops. The system preference takes precedence.booleanfalse
disabledSuspend interaction and cancel active gestures.booleanfalse

Information

Category
Dot Matrix
Files
  • components/dot-matrix/MatrixGlyph.tsx
  • components/dot-matrix/MatrixPlayback.tsx
  • components/dot-matrix/context.tsx
  • components/dot-matrix/dot-matrix.css
  • components/dot-matrix/glyphs.ts
  • components/dot-matrix/motion.ts
  • components/dot-matrix/playback-entry.ts
  • components/dot-matrix/playback.ts
  • components/dot-matrix/tokens.ts
  • components/dot-matrix/usePlaybackHead.ts
Dependencies
React 18.2 or 19 and React DOM; no additional runtime packages
Compatibility
React 18.2 / 19, TypeScript, CSS imports, Modern browsers
Version
0.1.0
Updated
Oct 1, 2026
Registry
dot-matrix-playback