npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 motion and animates to the OS default.
  • Themed with a small set of --bx-* variables; auto light/dark via prefers-color-scheme, per-usage override supported.
  • SSR-safe: nothing touches window/document at module scope.
  • Tree-shakeable ESM + CJS with type declarations.

Install

npm install @sauveurcadetorg/media-controls

react 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) and formatTime(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/togglePictureInPicture

useMediaSession(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" with aria-valuemin/max/now and a live aria-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 honors Escape, 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 report aria-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.