Media Player

Audio and video player UI patterns — play/pause, scrubber, volume, track list, and full-screen controls. These patterns cover the visual layer; wire them to a real audio/video element or AVPlayer in production.

Audio player

Late Night Drive

Neon Pulse

0:003:34
80

Play/pause, skip, scrub, adjust volume, and switch tracks. Shuffle and Repeat toggle their accent state.

Video player

Controls auto-hide 2.5 s after the last mouse move while playing. They reappear on any interaction. The fullscreen button expands the player to fill the viewport (within this demo context).

PREVIEW
HD
0:56 / 5:12

Sitka Design System — Component Walkthrough

5:12 · 42K views · 3 days ago

Controls anatomy

ControlAudioVideoNotes
Play / PausePrimary action. Centered and large. Keyboard: Space.
ScrubberRange input. Show elapsed / total time beside it.
VolumeSlider + mute toggle. Remember last non-zero volume.
Skip / SeekAudio: skip track. Video: ±10 s seek.
Shuffle / RepeatAudio playlist controls. Toggle accent when active.
Track listVisible queue. Tap to switch track instantly.
FullscreenExpand to viewport. Use Fullscreen API in production.
Quality badgeHD / 4K / SD label. Positioned top-right.
Auto-hideControls fade out 2–3 s after last interaction while playing.

Implementation

In this demo, progress advances via a setInterval ticker so it works without a real media file. In production, drive state from the native <audio> or <video> element's timeupdate event. Seek by setting element.currentTime directly.

AudioPlayer.tsx
tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib";

interface Track {
  title: string;
  artist: string;
  duration: number; // seconds
  src: string;
}

export function AudioPlayer({ tracks }: { tracks: Track[] }) {
  const [trackIdx, setTrackIdx] = useState(0);
  const [playing, setPlaying]   = useState(false);
  const [progress, setProgress] = useState(0); // 0–1
  const [volume, setVolume]     = useState(0.8);
  const audioRef = useRef<HTMLAudioElement>(null);

  const track = tracks[trackIdx];

  // Sync progress from real <audio> element
  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) return;

    const onTimeUpdate = () => {
      if (audio.duration) setProgress(audio.currentTime / audio.duration);
    };
    const onEnded = () => { setPlaying(false); setProgress(0); };

    audio.addEventListener("timeupdate", onTimeUpdate);
    audio.addEventListener("ended",      onEnded);
    return () => {
      audio.removeEventListener("timeupdate", onTimeUpdate);
      audio.removeEventListener("ended",      onEnded);
    };
  }, []);

  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) return;
    playing ? audio.play() : audio.pause();
  }, [playing]);

  useEffect(() => {
    if (audioRef.current) audioRef.current.volume = volume;
  }, [volume]);

  function seek(ratio: number) {
    const audio = audioRef.current;
    if (audio?.duration) audio.currentTime = ratio * audio.duration;
    setProgress(ratio);
  }

  function changeTrack(dir: 1 | -1) {
    setTrackIdx((i) => (i + dir + tracks.length) % tracks.length);
    setProgress(0);
    setPlaying(false);
  }

  const elapsed = Math.round(progress * track.duration);

  return (
    <div className="rounded-2xl border border-border bg-surface overflow-hidden shadow-lg">
      {/* Hidden audio element */}
      <audio ref={audioRef} src={track.src} />

      {/* Controls */}
      <div className="p-4 space-y-4">
        <div className="flex items-center justify-between">
          <div>
            <p className="font-semibold text-text-primary">{track.title}</p>
            <p className="text-sm text-text-secondary">{track.artist}</p>
          </div>
        </div>

        {/* Scrubber */}
        <input
          type="range" min={0} max={1} step={0.001}
          value={progress}
          onChange={(e) => seek(parseFloat(e.target.value))}
          className="w-full accent-accent cursor-pointer"
        />

        <div className="flex items-center justify-between">
          <span className="text-xs text-text-tertiary font-mono">{formatTime(elapsed)}</span>
          <span className="text-xs text-text-tertiary font-mono">{formatTime(track.duration)}</span>
        </div>

        {/* Playback */}
        <div className="flex items-center justify-center gap-6">
          <button onClick={() => changeTrack(-1)}>⏮</button>
          <button
            onClick={() => setPlaying(!playing)}
            className="w-12 h-12 rounded-full bg-accent text-white flex items-center justify-center"
          >
            {playing ? "⏸" : "▶"}
          </button>
          <button onClick={() => changeTrack(1)}>⏭</button>
        </div>

        {/* Volume */}
        <input
          type="range" min={0} max={1} step={0.01}
          value={volume}
          onChange={(e) => setVolume(parseFloat(e.target.value))}
          className="w-full accent-accent"
        />
      </div>
    </div>
  );
}

function formatTime(s: number) {
  const m = Math.floor(s / 60);
  return `${m}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

Design decisions

  • Native range input for scrubbing. Use a styled <input type="range"> rather than a custom drag handler. It gives you keyboard seek (arrow keys), screen reader value announcements, and touch support for free. Use accent-color to match the design token.
  • Play button is the visual anchor. Make the play/pause button the largest element — 48–56px on audio, prominent in the center of the video frame. It is the primary action and should be reachable without reading the interface.
  • Auto-hide controls on video. Fade controls out after 2–3 s of inactivity while playing. Reset the timer on any mousemove, click, or keypress. Always show controls when paused.
  • Remember volume across sessions. Persist volume to localStorage. Nothing is more jarring than a page blasting at 100% when the user had left it at 30%. Mute toggle should restore the previous volume, not set to zero permanently.
  • Never autoplay with sound. Browsers block autoplay with audio. If you must autoplay (e.g., a preview), start muted. Provide a clear unmute button. Respect prefers-reduced-motion for any animated transitions.

Accessibility

  • Wrap the player in role="region" with aria-label="Audio player" or "Video player" so screen reader users can jump to it.
  • The scrubber needs aria-label="Seek" and aria-valuetext="1:23 of 3:34" (not just the raw 0–1 ratio).
  • Play/pause button must toggle its aria-label between "Play" and "Pause" — never just use an icon without a label.
  • Mute button: aria-pressed="true" when muted. Screen readers announce state changes on toggle.
  • Keyboard: Space = play/pause, Arrow keys = seek ±5 s (on the scrubber), M = mute, F = fullscreen. These are well-known conventions.
  • Provide captions/subtitles for video with spoken content. Use the <track> element with kind="captions".