Duck Audio Player
The native audio bar, replaced: one <audio> element with the seek bar, the tap and a QuackButton transport wired to it, in a full card or a single compact row.
InputsClient componentlucide-react
Preview
Pond at dawn — field recording
0:00 / --:--
Voice note
14 Mar0:00 / --:--
Installation
The CLI writes the source into your project and pulls in whatever it depends on.
$
pnpm dlx shadcn@latest add @duck/duck-audio-playerPulls in @duck/theme, @duck/duck-media-slider, @duck/duck-volume, @duck/quack-button. Already installed items are skipped.
Usage
import { DuckAudioPlayer } from "@/components/ui/duck-audio-player"The file lands in components/ui/duck-audio-player.tsx and belongs to you from that point on. Edit it in place rather than wrapping it.
Props
Everything not listed here is forwarded to the underlying element.
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | - | The audio. Changing it runs the element's load algorithm, which empties it — position, duration and buffered range all reset themselves from the events, with no remount needed. |
| title | string | - | Shown in the default layout, and folded into the accessible name of every control. A string, not a node, for exactly that reason: four players on a page must not all have a button called Pause. |
| compact | boolean | false | One row — play, timecode, seek, volume — with the dense 4px seek line and no frame of its own, for a list row that already has one. The default layout is a sticker card with the title, the skip pair and a full-width bar. |
| defaultVolume / defaultMuted | number / boolean | 0.7 / false | Starting level, applied to the element in an effect because volume and muted are properties rather than attributes. DuckVolume owns them from then on. |
| skip | number | 15 | Seconds the back and forward buttons move. Default layout only, and disabled whenever the media is not seekable. |
| loop / preload | native | false / "metadata" | Passed straight to the element. Metadata by default so the duration is known before the first play and the bar does not appear from nowhere. |
Rules
What keeps this component from turning into noise.
- duration is null until the element knows, and stays null for anything with no finite length. NaN before metadata and Infinity on a live stream are the same fact — there is no bar to draw — so the slider is disabled, the read-out says --:-- and the status line says Live.
- buffered is read from the range holding the playhead, not from the last range. A seek into fresh territory starts a new range and leaves a hole, and the slider draws one fill from zero.
- The seek bar owns its value while you drag it, so timeupdate runs straight into state and currentTime is written once, on commit. Do not add a second source of position.
- The loading read on the transport only claims the first load, before the element is playable at all. QuackButton disables itself while busy, and taking Pause away from someone waiting out a mid-track stall would be worse than saying nothing.
- No controls on the element, so it has no box and no tab stop: one set of controls, nothing duplicated for a keyboard or a screen reader.
- Compact draws no frame. It is meant to sit inside a row that has one, and two sticker edges nested inside one another read as a mistake.
