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
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).
Sitka Design System — Component Walkthrough
5:12 · 42K views · 3 days ago
Controls anatomy
| Control | Audio | Video | Notes |
|---|---|---|---|
| Play / Pause | ✓ | ✓ | Primary action. Centered and large. Keyboard: Space. |
| Scrubber | ✓ | ✓ | Range input. Show elapsed / total time beside it. |
| Volume | ✓ | ✓ | Slider + mute toggle. Remember last non-zero volume. |
| Skip / Seek | ✓ | ✓ | Audio: skip track. Video: ±10 s seek. |
| Shuffle / Repeat | ✓ | — | Audio playlist controls. Toggle accent when active. |
| Track list | ✓ | — | Visible queue. Tap to switch track instantly. |
| Fullscreen | — | ✓ | Expand to viewport. Use Fullscreen API in production. |
| Quality badge | — | ✓ | HD / 4K / SD label. Positioned top-right. |
| Auto-hide | — | ✓ | Controls 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.
"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".