Media

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.

How to install Pro components →See the plans →

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>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

0.8
1
Active edited usageDefault props
<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

PropTypeDefaultDescription
audioSrcstring—Audio URL; when omitted, a built-in ~12 s synthesized melody is used (it changes with sideLabel), intended for demos
captionTracksCassetteCaptionTrack[]—Caption tracks; when present, a captions toggle appears in the controls
trackTitlestring—Track title, written on the label and used as the player's aria-label
archiveLabelstring—Label subtitle, e.g. the recording source or year
catalogueNumberstring—Catalogue number, shown in monospace at the top right of the label
sideLabelstring"A"Tape side, shown as a large letter on the left of the label
initialVolumenumber0.8Initial volume (0–1), read only on mount
loopbooleanfalseWhether to loop back to the start when playback ends
reelSpeednumber1Reel 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
grainbooleanfalseOverlay fine grain on the shell
classNamestring—Passed through to the outermost container

CassetteCaptionTrack

FieldTypeDefaultDescription
srcstring—URL of a WebVTT file
vttstring—Raw WebVTT text (including the WEBVTT header), turned into a Blob URL automatically; takes precedence over src
srclangstring—Language code, e.g. en or zh-Hant
labelstring—Track name
defaultbooleanfalseWhether this is the default track; captions start on when any track sets this

CassettePlaybackState

FieldTypeDefaultDescription
playingboolean—Whether audio is playing
timenumber—Current playback time (seconds)
durationnumber—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.currentTime instead of relying on timeupdate (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 currentTime is 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 same audio element, 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: vtt strings become Blob URLs on track elements; the selected track is loaded in hidden mode and the component renders activeCues below 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 its aria-label; the cassette shell is decorative (aria-hidden) and the audio element shows no native controls
  • Every control is a native button with an aria-label (play/pause, rewind, fast forward 10 seconds, mute, captions); mute and captions toggles carry aria-pressed, and all show a focus-visible ring
  • The scrubber is role="slider" with aria-valuetext such 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-only aria-live region
  • 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