The playback position bar for an <audio> or <video> element you already have — the filled progress, the buffered-ahead shading, a draggable thumb, keyboard seeking, and a screen-reader announcement that says "1 minute 12 seconds of 5 minutes 0 seconds" instead of a bare number. It attaches to your own media element by ref rather than rendering one, so it stays a position bar and leaves the transport buttons, layout and sources to you. Reach for it wherever media has a timeline: a custom audio player, a podcast or audiobook player, a voice-note or voice-memo field, a video player or preview, a lecture, webinar or course-recording player, a meeting or call recording, a screen-recording review, a security-camera or dashcam playback view, a music or sample preview, a transcript synced to audio, a video trimming or clip-selection UI, and the playback half of a recorder (it pairs with audio-recorder in this registry, which records but hands playback to the browser's own control). It is built around the four things that make a seek bar quietly wrong, each measured rather than assumed. (1) duration is NaN before metadata loads and Infinity for a live stream — and for every clip MediaRecorder produces — so pointerX / width * duration is NaN exactly on first paint; assigning a non-finite currentTime throws TypeError rather than seeking, which unwinds the handler and leaves a drag stuck to the pointer. Every path here is guarded for finiteness, not for range, because the element already clamps finite out-of-range values itself. A duration prop covers the MediaRecorder case where the file never learns its own length. (2) buffered is a TimeRanges, which is not iterable, has no .map, and has no index properties: [...buffered] throws, and Array.from(buffered) succeeds and lies — it returns an array of the right length full of undefined. It is read by index here, and the buffered bar stops at the range containing the playhead rather than the last range, so a buffer with a hole in front of the playhead is not drawn as a clip that will play through. (3) timeupdate fires at most four times a second, so the bar reads currentTime on an animation frame instead — re-reading the element, never accumulating its own clock — and commits only when the playhead would move to a different position on the track. (4) the thumb follows the pointer during a drag rather than currentTime, because seeking is asynchronous and the element keeps reporting the old position until it completes, so a bar rendered from currentTime crawls out from under the finger. Accessibility is the part a hand-rolled bar usually skips: role=slider with aria-valuemin/max/now and an aria-valuetext in words, all three value attributes withheld together while the length is unknown (publishing valuenow alone states 0% of a clip nobody has measured, because ARIA's implied maximum is 100), aria-disabled until a seek is actually possible, Arrow and Page keys, Home and End, a focus-visible ring on the thumb, horizontal arrows mirrored under RTL while the vertical ones are not, touch-none so a drag on a phone is a seek and not a page scroll, pointer capture with pointer-id checks so a second finger cannot move or end someone else's drag, and the release's own coordinate committed rather than the last coalesced pointermove. Common asks it answers: "audio player progress bar react", "video seek bar react", "custom audio player scrubber react", "react media player progress bar", "shadcn audio player", "shadcn video player progress bar", "shadcn seek bar", "shadcn media scrubber", "html5 audio currentTime react", "react audio seek slider", "accessible audio seek bar", "aria slider for audio position", "screen reader audio player position", "audio buffered progress bar react", "show buffered range audio react", "video buffered bar react", "buffered.map is not a function", "buffered is not iterable", "TimeRanges not iterable", "Array.from buffered empty", "how to read TimeRanges", "audio duration NaN", "video duration NaN react", "duration is Infinity audio", "MediaRecorder blob duration Infinity", "recorded audio duration Infinity", "Failed to set currentTime non-finite", "currentTime TypeError non-finite", "setting currentTime throws", "seek bar thumb jumps while dragging", "progress bar jumps back after seek", "scrubber lags behind cursor", "choppy audio progress bar", "timeupdate only fires 4 times a second", "smooth progress bar requestAnimationFrame audio", "progress bar stutters during playback", "seek bar does not work on mobile", "drag seek bar scrolls the page instead", "live stream duration Infinity seek bar", "podcast player seek bar react", "voice note playback react", "keyboard accessible video scrubber", "seek with arrow keys react", "next.js audio player progress bar", "tailwind audio seek bar", "typescript audio scrubber component", "音声 シークバー react", "動画 再生位置 バー", "オーディオプレイヤー 進捗バー", "シークバー 自作 react", "再生位置 アクセシビリティ", "ボイスメモ 再生 react". Different from official shadcn/ui, which ships nothing for media at all — measured across all 62 of its components and 223,287 bytes of source, currentTime, buffered, seeking, TimeRanges, <audio>, <video>, scrubber, seekable and playbackRate are all zero hits, and so is role="slider". Its slider is a Radix value slider with no notion of time, buffered ranges, an unknown or unbounded duration, or a position announced as a clock reading; its progress is a display-only bar you cannot seek with. Distinct from the neighbours in this registry: `audio-recorder` is the capture half and hands playback to the browser, `duration-input` types a length rather than seeking one, `progress-ring` and `ratio-bar` display a proportion you cannot drag, `countdown` counts time down rather than pointing at a position in it, and `scroll-progress` tracks the page rather than a media element.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/media-scrubber.json"