Cassette Player
A skeuomorphic cassette audio player: reels spin with playback time, tape spools grow and shrink with progress, plus an animated rewind, a draggable scrubber and caption tracks.
This is a WebberUI Pro component
The install command below needs a licence to run; without one it returns 401.
An audio player where you can watch the tape move. The reels read audio.currentTime on every requestAnimationFrame, the supply pack on the left shrinks with progress while the take-up pack on the right grows, and pressing rewind spins the displayed time back to the start at a speed that scales with distance, with the reels turning backwards. The scrubber can be dragged, clicked or driven by keyboard, and there is a 10-second fast forward, mute, volume and caption tracks. The label carries the title, subtitle, catalogue number and side A/B, with three customizable stripes. When no audioSrc is passed, the component synthesizes a short ~12-second melody in the browser so you can try it without any audio file; in production, pass your own audio URL.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add "https://webberui.com/r/cassette-player.en.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<CassettePlayer/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add "https://webberui.com/r/cassette-player.en.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/cassette-player.
Usage
import { CassettePlayer } from "@/components/ui/cassette-player";
<CassettePlayer
audioSrc="/audio/afternoon-walk.mp3"
trackTitle="午後散步"
archiveLabel="家庭錄音帶 1987"
catalogueNumber="WB-0042"
sideLabel="A"
captionTracks={[
{
vtt: "WEBVTT\n\n00:00.000 --> 00:04.000\n午後的巷口,風把曬衣繩吹得輕輕搖晃。\n",
srclang: "zh-Hant",
label: "繁體中文",
default: true,
},
]}
onPlaybackChange={(state) => console.log(state.playing, state.time)}
/>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
audioSrc | string | — | Audio URL; when omitted, a built-in ~12 s synthesized melody is used (it changes with sideLabel), intended for demos |
captionTracks | CassetteCaptionTrack[] | — | Caption tracks; when present, a captions toggle appears in the controls |
trackTitle | string | — | Track title, written on the label and used as the player's aria-label |
archiveLabel | string | — | Label subtitle, e.g. the recording source or year |
catalogueNumber | string | — | Catalogue number, shown in monospace at the top right of the label |
sideLabel | string | "A" | Tape side, shown as a large letter on the left of the label |
initialVolume | number | 0.8 | Initial volume (0–1), read only on mount |
loop | boolean | false | Whether to loop back to the start when playback ends |
reelSpeed | number | 1 | Reel speed multiplier; 1 means 300 degrees per second |
stripes | [string, string, string] | ["#e2583e", "#f2a541", "#3f88c5"] | The label's three stripes (top to bottom); when omitted, the --wb-cassette-stripe-1..3 variables are read, and the defaults apply only if those are unset |
onPlaybackChange | (state: CassettePlaybackState) => void | — | Called on play, pause, seek and load |
grain | boolean | false | Overlay fine grain on the shell |
className | string | — | Passed through to the outermost container |
CassetteCaptionTrack
| Field | Type | Default | Description |
|---|---|---|---|
src | string | — | URL of a WebVTT file |
vtt | string | — | Raw WebVTT text (including the WEBVTT header), turned into a Blob URL automatically; takes precedence over src |
srclang | string | — | Language code, e.g. en or zh-Hant |
label | string | — | Track name |
default | boolean | false | Whether this is the default track; captions start on when any track sets this |
CassettePlaybackState
| Field | Type | Default | Description |
|---|---|---|---|
playing | boolean | — | Whether audio is playing |
time | number | — | Current playback time (seconds) |
duration | number | — | Total duration (seconds); 0 until metadata has loaded |
The CassettePlayerProps, CassetteCaptionTrack and CassettePlaybackState types are also exported by name.
How it works
- Sync loop: while playing, requestAnimationFrame reads
audio.currentTimeinstead of relying ontimeupdate(about 4Hz, which makes the reels stutter). Reel angle = time × 300 ×reelSpeed(mod 360); the supply pack scales linearly from 1 down to 0.6 and the take-up pack from 0.6 up to 1. These values are written to motion values, so frames do not trigger React re-renders - Rewind: the sync loop and playback pause, and the displayed time animates from its current position back to 0 over 220ms plus 40ms per second of distance, capped at 1 second, with the reels spinning backwards. Afterwards
currentTimeis set to 0 and playback resumes if it was playing - Built-in synthesized source: without
audioSrc, the component synthesizes 24 notes sample by sample in plain JS (sine plus triangle, attack/release envelope, a little tape hiss and wow), encodes them as a WAV Blob and feeds that to the sameaudioelement, so seeking, rewinding and looping behave exactly as with a real file; the Blob URL is revoked on unmount. Sides A and B get different melodies - Captions:
vttstrings become Blob URLs ontrackelements; the selected track is loaded in hidden mode and the component rendersactiveCuesbelow the shell itself - Theme variables:
--wb-cassette-duration(button transitions, 150ms by default),--wb-cassette-ease(ease-out by default),--wb-cassette-stripe-1..3(stripes),--wb-cassette-paper(label paper) and--wb-cassette-tape(tape colour) - Label text scales with container width (cqw), so the label grows with the player
Accessibility
- The wrapper is
role="group"with the track title as itsaria-label; the cassette shell is decorative (aria-hidden) and theaudioelement shows no native controls - Every control is a native
buttonwith anaria-label(play/pause, rewind, fast forward 10 seconds, mute, captions); mute and captions toggles carryaria-pressed, and all show afocus-visiblering - The scrubber is
role="slider"witharia-valuetextsuch as "1 min 02 sec of 3 min 10 sec"; ←/→ move 5 seconds, Home/End jump to the start or end, and the pointer can drag or click to seek - Playback changes (playing, paused, rewinding, finished) are announced through an
sr-onlyaria-liveregion - When the user has reduce motion enabled, the reels and tape packs freeze at their current proportions instead of turning, rewind jumps straight to the start without animation, and everything else works as usual
Lightbox Gallery
A React thumbnail wall that opens a full-screen lightbox: the image morphs in from its thumbnail as a shared element, with wheel/pinch zoom and drag panning, double-tap zoom, swipe and keyboard navigation, a counter and caption bar, a bottom thumbnail strip, and Esc or backdrop to close.
Device Stage 3D
A pure-CSS 3D stage for phones, tablets and laptops with five arrangements: pointer tilt, scroll orbit, Y-axis flip, a fanned lineup of devices and a scroll-driven macro pull-back; screens are live DOM.