@sauveurcadetorg/media-controls
v1.3.0
Published
Apple-style media playback controls for React: a scrubber with video seek preview, a rubber-banding volume slider, and a speed sheet. ESM + CJS, tree-shakeable, SSR-safe, themeable via CSS variables.
Downloads
1,259
Maintainers
Readme
@sauveurcadetorg/media-controls
Apple-style media controls for React: a scrubber, a volume slider, a speed sheet, plus the playback building blocks (video surface, auto-hiding overlay, transport buttons, buffering, title marquee, progress, fullscreen/PiP) and a headless engine with lock-screen support. Designed to drop into your own video/audio UI, or compose a full player from blocks.
- Zero-opinion markup, plain CSS that you own — import one stylesheet.
- Motion is powered by
motionand animates to the OS default. - Themed with a small set of
--bx-*variables; auto light/dark viaprefers-color-scheme, per-usage override supported. - SSR-safe: nothing touches
window/documentat module scope. - Tree-shakeable ESM + CJS with type declarations.
Install
npm install @sauveurcadetorg/media-controlsreact and react-dom (18+) are peer dependencies; motion is bundled as a
runtime dependency automatically.
Import the stylesheet once:
import "@sauveurcadetorg/media-controls/styles.css";Components
Scrubber
A role="slider" seek bar with keyboard support (arrows step 5s,
Home/End jump to ends), tap/drag seeking, and a halftone --bx-accent fill.
import { Scrubber } from "@sauveurcadetorg/media-controls";
<Scrubber
duration={92}
currentTime={31}
onSeek={setTime}
onSeekEnd={() => video.currentTime = pending}
ariaLabel="Seek"
/>;| Prop | Type | Notes |
| --- | --- | --- |
| duration | number | Total seconds. |
| currentTime | number | Current position in seconds. |
| onSeek | (t: number) => void | Fires on any seek intent (throttled to animation frames). |
| onSeekEnd | (t: number) => void | Fires when the user releases/commits a seek. |
| onScrubbingChange | (scrubbing: boolean) => void | Drag start/end — wire to ControlsOverlay to pin controls. |
| showTime | boolean | Built-in 0:00 / -0:00 readout under the bar (default true). Set false when you pair it with ProgressTracker so the times aren't shown twice. |
| ariaLabel | string | Fallback aria-label (defaults to an English label). |
| style/className | CSS props | Styled via --bx-* vars, so plain style works. |
VolumeSlider
Clamped volume control: drags are clamped to 0..1 (no rubber-banding). Optionally
shows a level-adaptive speaker icon and supports horizontal and vertical layouts.
import { VolumeSlider } from "@sauveurcadetorg/media-controls";
<VolumeSlider value={0.62} onValueChange={setVolume} onCommit={setVolume} icon />;
<VolumeSlider orientation="vertical" verticalLength={140} value={v} onValueChange={setV} />;| Prop | Type | Notes |
| --- | --- | --- |
| value | number | 0..1. |
| onValueChange | (v: number) => void | Live while dragging. |
| onCommit | (v: number) => void | Fires on release. |
| orientation | "horizontal" \| "vertical" | Default horizontal. |
| verticalLength | number | px height in vertical mode. |
| icon | boolean | Show a speaker icon whose glyph tracks the level. |
SpeedSheet
Playback-rate picker. A popover that opens upward from the trigger —
flipping below when it doesn't fit — so it stays on screen even on mobile or at
the top of a viewport. Controlled or uncontrolled, with Escape and
tap-outside dismiss built in.
import { SpeedSheet, DEFAULT_RATES } from "@sauveurcadetorg/media-controls";
<SpeedSheet
value={rate}
onChange={setRate}
rates={DEFAULT_RATES} // [0.5, 0.75, 1, 1.25, 1.5, 2]
trigger={({ open, rate }) => <span className="pill">{rate}x</span>} // render-prop, or a ReactNode
/>;| Prop | Type | Notes |
| --- | --- | --- |
| value | number | Current rate (auto-added to the list when missing). |
| onChange | (r: number) => void | Called with the picked rate. |
| rates | number[] | Defaults to DEFAULT_RATES. |
| trigger | ReactNode \| ({ open, rate }) => ReactNode | The button that opens the sheet. |
formatRate(r)andformatTime(s)are exported from the package root, too.
Playback building blocks
Everything below is a self-contained block: wire it to your own state or to
useMediaController. They share the bx- class system and the --bx-* tokens,
so they theme and stack together.
useMediaController (headless engine)
No DOM of its own — returns a mediaRef to attach to your <video>/<audio> (or
VideoSurface.videoRef) plus state and actions. Handles native mp4/webm, and lazy-loads
hls.js / dashjs when the source says type: "hls" / "dash" (install those
packages yourself; errors surface in state.error).
import { useMediaController } from "@sauveurcadetorg/media-controls";
const c = useMediaController({ source: "movie.mp4", autoPlay: true, muted: true });
// mediaRef, state.{playing,currentTime,duration,buffering,volume,rate,...},
// play/pause/toggle/seek/skip/setVolume/setRate/requestFullscreen/togglePictureInPictureuseMediaSession(controller, meta) (or the <MediaSession> block) syncs
navigator.mediaSession — metadata, artwork, play/pause/skip + previous/next
handlers (±10s defaults), and setPositionState. No-ops where unsupported.
Note: if you render the engine's mediaRef on a bare <video> you need to cast
the ref; the idiomatic attach point is VideoSurface.videoRef.
VideoSurface
Letterboxed <video> container (fit="contain" by default, poster, aspectRatio,
--bx-letterbox background). Children — the overlay, buffering spinner — slot above
it.
<VideoSurface src={url} videoRef={c.mediaRef} aspectRatio="16 / 9">
<ControlsOverlay playing={c.state.playing}>…</ControlsOverlay>
</VideoSurface>ControlsOverlay
Auto-hiding controls layer: while playing it fades out after idleDelay (3000ms);
a tap (also a pointer move) brings it back; it pins open while scrubbing or paused.
Interactive children keep pointer-events: auto; empty-space taps never hit buttons.
<ControlsOverlay playing={playing} scrubbing={scrubbing} idleDelay={2500}>
<BufferingIndicator buffering={buffering} />
<div className="bx-overlay__item">{buttons…}</div>
</ControlsOverlay>Buttons
| Block | Props | Notes |
| --- | --- | --- |
| PlayPauseButton | playing, onToggle, onPlay, onPause, size | Round glass play/pause. |
| SkipButton / SkipButtons | direction/backSeconds/forwardSeconds, onSkip(delta) | SF-style 10s skips; the number is drawn into the glyph. |
| FullscreenButton | videoRef, active, onActiveChange | Renders nothing where unsupported. Fullscreens the video's wrapper (its parent element), so the browser keeps our overlay and never attaches its native fullscreen media UI. On iPhone/iOS Safari — where arbitrary elements can't be fullscreened — it falls back to the video element's native webkitEnterFullscreen path instead. |
| PictureInPictureButton | videoRef, active, onActiveChange | Same, for PiP. |
<SkipButtons onSkip={c.skip} />
<PlayPauseButton playing={c.state.playing} onToggle={c.toggle} />
<FullscreenButton videoRef={c.mediaRef} />
<PictureInPictureButton videoRef={c.mediaRef} />BufferingIndicator · TitleBlock · ProgressTracker
<BufferingIndicator buffering={c.state.buffering} /> {/* role="status" spinner */}
<TitleBlock title="Please Please Please" subtitle="Sabrina Carpenter" />
<ProgressTracker currentTime={c.state.currentTime} duration={c.state.duration} />TitleBlock scrolls its title (Apple-Music style, pauses on hover) only when the
text overflows — pass forceMarquee to always scroll. ProgressTracker shows
1:23 – 4:37 (elapsed / remaining; mode="elapsed-total" available).
When you pair ProgressTracker with a Scrubber, hide the Scrubber's own readout
so the times aren't duplicated:
<ProgressTracker currentTime={t} duration={d} />
<Scrubber currentTime={t} duration={d} onSeek={seek} showTime={false} />MediaIcons — replace any glyph
Every control reads its glyph from a MediaIcons provider overrides map, so you
surface your own icon set — there are no baked-in SVGs, so controls render plain
glass circles until you supply icons (e.g. Phosphor):
<MediaIcons
icons={{
play: <MyPlay />,
pause: <MyPause />,
skipBack: ({ seconds }) => <MyBack n={seconds} />,
fullscreen: <FS />,
fullscreenExit: <FSX />,
pictureInPicture: <Pip />,
pictureInPictureExit: <PipX />,
speakerMuted: <Mute />, speakerLow: <Low />, speakerHigh: <High />,
check: <Check />, chevronDown: <Chevron />,
}}
>
{blocks}
</MediaIcons>Putting it together
function Player() {
const c = useMediaController({ source: "show.mp4" });
const [scrubbing, setScrubbing] = useState(false);
return (
<VideoSurface src="show.mp4" videoRef={c.mediaRef} aspectRatio="16 / 9">
<BufferingIndicator buffering={c.state.buffering} />
<ControlsOverlay playing={c.state.playing} scrubbing={scrubbing}>
<div className="bx-overlay__item">
<SkipButtons onSkip={c.skip} />
<PlayPauseButton playing={c.state.playing} onToggle={c.toggle} />
<SkipButtons onSkip={c.skip} />
</div>
</ControlsOverlay>
<Scrubber
duration={c.state.duration}
currentTime={c.state.currentTime}
onSeek={c.seek}
onScrubbingChange={setScrubbing}
/>
<div className="bx-overlay__item">
<FullscreenButton videoRef={c.mediaRef} />
<PictureInPictureButton videoRef={c.mediaRef} />
</div>
</VideoSurface>
);
}Styling
Import the single stylesheet, then theme with CSS variables scoped to .bx-root:
.bx-root {
--bx-accent: #0a84ff; /* primary; slider fill */
--bx-text: #1c1c1e; /* labels / times */
--bx-text-secondary: rgba(60, 60, 67, .6);
--bx-track: rgba(120, 120, 128, .28);/* slider / popover track */
--bx-glass: rgba(248, 248, 250, .86);/* popover / sheet chrome */
--bx-button-glass: rgba(22, 22, 26, .34); /* video overlay buttons */
--bx-button-glass-hover: rgba(255, 255, 255, .22);
--bx-overlay-scrim: linear-gradient(180deg, rgba(0,0,0,.42), transparent 32%, transparent 60%, rgba(0,0,0,.55));
--bx-buffering-fg: #fff;
--bx-letterbox: transparent; /* video surface surround */
--bx-marquee-gap: 48px; /* title marquee spacing */
--bx-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
}Dark/light adapt automatically. Force a scheme on an element with
data-bx-theme="dark" or data-bx-theme="light" (see the dev example).
Accessibility
- Scrubber is a proper
role="slider"witharia-valuemin/max/nowand a livearia-valuetext("1:23 of 3:05"); full keyboard support. - VolumeSlider likewise
role="slider", arrows adjust by 0.05. - SpeedSheet is a
role="listbox"popover that honorsEscape, closes on tap-outside, and marks the current rate. - PlayPauseButton / SkipButton / Fullscreen / PiP are real buttons with dynamic
aria-labels ("Forward 10 Seconds"); Fullscreen/PiP reportaria-pressed. - BufferingIndicator is
role="status"(aria-live="polite"), so it is announced but never steals focus.
Server-side rendering
All browser APIs live in effects, so every component renders safely via
renderToString. Style side effects are limited to the stylesheet import.
Development
npm install
npm test # vitest (behavior + SSR smoke)
npm run dev # interactive playground at examples/dev
npm run build # tsup -> dist (ESM/CJS/d.ts + styles.css)The dev playground maps the package name to the local source via Vite aliases, so you're always editing against real code.
License
Apache License 2.0 — free for personal and commercial use, modify and redistribute freely. Keep the original copyright/attribution notices; don't imply the project endorses you or pass the work off as your own. See the LICENSE file for the full text.
