Media
Audio player
Custom controls for a native audio element: play, seek with buffering, skip, speed and volume, or one line.
- Built on
<audio><input type="range">Media Session APIContainer queries- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 5.9 kB
- Built from
- Icon
Code
"use client";
import { useEffect, useState } from "react";
import { AudioPlayer } from "@/components/equip/audio-player";
/* A 24 second plucked arpeggio, written as a WAV in the browser, so the demo fetches nothing. */
function arpeggio(seconds = 24, rate = 8000) {
const n = seconds * rate;
const view = new DataView(new ArrayBuffer(44 + n * 2));
const word = (at: number, text: string) => [...text].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
word(0, "RIFF"); view.setUint32(4, 36 + n * 2, true); word(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
word(36, "data"); view.setUint32(40, n * 2, true);
const notes = [220, 277.18, 329.63, 440, 329.63, 277.18];
for (let i = 0; i < n; i++) {
const t = i / rate;
const note = notes[Math.floor(t * 3) % notes.length] ?? 220;
view.setInt16(44 + i * 2, Math.sin(2 * Math.PI * note * t) * Math.exp(-6 * (t % (1 / 3))) * 6000, true);
}
return new Blob([view.buffer], { type: "audio/wav" });
}
export default function Demo() {
const [src, setSrc] = useState<string>();
useEffect(() => {
const url = URL.createObjectURL(arpeggio());
setSrc(url);
return () => URL.revokeObjectURL(url);
}, []);
return <AudioPlayer src={src} title="Arpeggio in A" artist="Made in your browser" style={{ width: 216 }} />;
}Examples
Podcast episode
Space or K plays, the arrows seek and set the volume, J and L skip, and M mutes while focus is anywhere in the player.
Code
"use client";
import { useEffect, useState } from "react";
import { AudioPlayer } from "@/components/equip/audio-player";
/* Ninety seconds of soft tones, generated as a WAV so nothing is fetched. */
function tones(seconds = 90, rate = 8000) {
const n = seconds * rate;
const view = new DataView(new ArrayBuffer(44 + n * 2));
const word = (at: number, text: string) => [...text].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
word(0, "RIFF"); view.setUint32(4, 36 + n * 2, true); word(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
word(36, "data"); view.setUint32(40, n * 2, true);
const chords = [[196, 247, 294], [220, 262, 330], [175, 220, 262], [196, 247, 294]];
for (let i = 0; i < n; i++) {
const t = i / rate;
const chord = chords[Math.floor(t / 4) % chords.length] ?? [196];
const sample = chord.reduce((sum, f) => sum + Math.sin(2 * Math.PI * f * t), 0) / chord.length;
view.setInt16(44 + i * 2, sample * Math.min(1, (t % 4) * 4, (4 - (t % 4)) * 2) * 5000, true);
}
return new Blob([view.buffer], { type: "audio/wav" });
}
function Cover() {
return (
<svg viewBox="0 0 44 44" aria-hidden="true">
<rect width="44" height="44" style={{ fill: "color-mix(in srgb, var(--eq-chart-3) 35%, var(--eq-raised))" }} />
<circle cx="22" cy="19" r="7" style={{ fill: "none", stroke: "var(--eq-chart-3)", strokeWidth: 3 }} />
<path d="M22 26v8M16 34h12" style={{ stroke: "var(--eq-chart-3)", strokeWidth: 3, strokeLinecap: "round" }} />
</svg>
);
}
export default function Example() {
const [src, setSrc] = useState<string>();
useEffect(() => {
const url = URL.createObjectURL(tones());
setSrc(url);
return () => URL.revokeObjectURL(url);
}, []);
return (
<AudioPlayer
src={src}
title="Episode 41: Designing for slow networks"
artist="Field notes"
album="Field notes"
artwork={<Cover />}
style={{ maxWidth: 440 }}
/>
);
}Compact, in a message thread
The compact variant keeps play, position, time and mute on one line for voice notes and lists.
Priya RamanSent 10:42
Code
"use client";
import { useEffect, useState } from "react";
import { AudioPlayer } from "@/components/equip/audio-player";
/* A short voice-note stand-in: a gliding tone, generated as a WAV so nothing is fetched. */
function glide(seconds = 12, rate = 8000) {
const n = seconds * rate;
const view = new DataView(new ArrayBuffer(44 + n * 2));
const word = (at: number, text: string) => [...text].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
word(0, "RIFF"); view.setUint32(4, 36 + n * 2, true); word(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
word(36, "data"); view.setUint32(40, n * 2, true);
let phase = 0;
for (let i = 0; i < n; i++) {
const t = i / rate;
phase += (2 * Math.PI * (180 + 60 * Math.sin(t * 1.3))) / rate;
view.setInt16(44 + i * 2, Math.sin(phase) * (0.5 + 0.5 * Math.sin(t * 5)) * 5000, true);
}
return new Blob([view.buffer], { type: "audio/wav" });
}
export default function Example() {
const [src, setSrc] = useState<string>();
useEffect(() => {
const url = URL.createObjectURL(glide());
setSrc(url);
return () => URL.revokeObjectURL(url);
}, []);
return (
<div style={{ display: "grid", gap: 6, width: "100%", maxWidth: 320 }}>
<span style={{ fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>Priya Raman</span>
<AudioPlayer variant="compact" src={src} title="Voice note" label="Voice note from Priya Raman" />
<span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-3)" }}>Sent 10:42</span>
</div>
);
}Before a source is set
With no source the position, skips and times stay disabled and read --:--, so a slot can be laid out before the file exists.
Code
import { AudioPlayer } from "@/components/equip/audio-player";
export default function Example() {
return <AudioPlayer title="Interview with the night crew" artist="Recording in progress" style={{ maxWidth: 440 }} />;
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title | ReactNode | none | Track name, in bold. A string also goes to the Media Session and names the player. |
artist | ReactNode | none | Who made it, under the title. A string also goes to the Media Session. |
album | string | none | Album or show, for the Media Session only. |
artwork | ReactNode | none | Cover art, an img or svg drawn as a 44px square. Full variant only. |
variant | "full" | "compact" | "full" | Full has artwork, skips, speed and volume; compact is play, position, time and mute on one line. |
skip | number | 15 | Seconds the skip buttons and J and L jump. |
rates | number[] | [0.75, 1, 1.25, 1.5, 2] | Speeds the speed button steps through. One rate or none hides it. |
label | string | none | Names the player group. Defaults to the title when it is a string, else "Audio player". |
mediaSession | boolean | true | On play, tells the system media controls what is playing and wires their buttons to this player. |
...rest | ComponentProps<"audio"> | none | Every native audio attribute and handler, including src, preload, loop, ref and source children. className and style go to the wrapper. |
Anatomy
| root | .eq-audio-player | A labelled group and the size container; data-variant is full or compact. |
|---|---|---|
| header | .eq-audio-player-header | Artwork and text, full variant. |
| artwork | .eq-audio-player-artwork | |
| meta | .eq-audio-player-meta | Title with artist or the error line. |
| title | .eq-audio-player-title | |
| artist | .eq-audio-player-artist | |
| error | .eq-audio-player-error | Replaces the artist when the source fails. |
| main | .eq-audio-player-main | Compact variant: title over the seek bar. |
| timeline | .eq-audio-player-timeline | Elapsed time, seek bar and length. |
| time | .eq-audio-player-time | |
| seek | .eq-audio-player-seek | A native range. The groove shows played, then buffered, then the rest. |
| controls | .eq-audio-player-controls | |
| play | .eq-audio-player-play | Round, solid. A ring turns round it while buffering. |
| button | .eq-audio-player-button | Skip, speed and mute; data-control tells them apart. |
| spacer | .eq-audio-player-spacer | |
| volume | .eq-audio-player-volume | A native range, hidden below 340px wide. |
Keyboard
| SpaceK | Plays or pauses, from anywhere in the player except a button, where Space presses it. |
|---|---|
| ArrowLeftArrowRight | Seeks back or forward 5 seconds. On the volume slider they change the volume instead. |
| JL | Skips back or forward by the skip amount. |
| ArrowUpArrowDown | Raises or lowers the volume by 10%. |
| M | Mutes or unmutes. |
| HomeEnd | Jumps to the start or the end. |
| Tab | Moves through the controls in order; nothing traps focus. |
Notes
- The native audio element does the playing; the part only draws controls and reads its events, so every native attribute and handler still works.
- The seek bar is a native range with aria-valuetext such as "1 minute 5 seconds of 3 minutes 20 seconds".
- Mute is a toggle button with aria-pressed, so assistive tech hears whether sound is off.
- Shortcut keys stop at the player, so they never reach page shortcuts while focus is inside it.
- The Media Session follows whichever player started last; turn it off with mediaSession={false} for previews.
- play() can be refused by autoplay rules; the refusal is caught and the button simply stays on Play.
- Below 340px the volume groove hides and the mute button and arrow keys remain.
- It is a size container, so it takes the full width of its box rather than sizing to its content; set a width or max-width to limit it.
- The demo and examples write a short WAV of generated tones in the browser, so they fetch nothing.
- Control names are English; copy the part and change them to translate.
- The Media Session API is missing in some engines; there the player works the same without system controls.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/audio-player.tsx427 lines
"use client";
import {
useEffect,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
type Ref,
} from "react";
import { Icon } from "./icon";
import "./audio-player.css";
export type AudioPlayerProps = Omit<ComponentProps<"audio">, "controls" | "title"> & {
/** The track name, shown in bold. A string also goes to the Media Session. */
title?: ReactNode;
/** Who made it. A string also goes to the Media Session. */
artist?: ReactNode;
/** Album or show name, for the Media Session only. */
album?: string;
/** Cover art: an img or svg, drawn as a square. */
artwork?: ReactNode;
/** Full shows artwork and every control; compact is one line. */
variant?: "full" | "compact";
/** Seconds the skip buttons jump. */
skip?: number;
/** Speeds the speed button steps through. */
rates?: number[];
/** Names the player for assistive tech. Defaults to the title when it is a string. */
label?: string;
/** Tells the system media controls what is playing, where the browser has them. */
mediaSession?: boolean;
};
type State = {
playing: boolean;
time: number;
duration: number;
buffered: number;
volume: number;
muted: boolean;
rate: number;
waiting: boolean;
failed: boolean;
};
const START: State = {
playing: false,
time: 0,
duration: Number.NaN,
buffered: 0,
volume: 1,
muted: false,
rate: 1,
waiting: false,
failed: false,
};
const EVENTS = [
"play",
"pause",
"playing",
"waiting",
"canplay",
"timeupdate",
"durationchange",
"loadedmetadata",
"progress",
"volumechange",
"ratechange",
"seeked",
"ended",
"emptied",
"error",
] as const;
/** 65 gives "1:05", 3725 gives "1:02:05", an unknown length gives "--:--". */
function clock(seconds: number): string {
if (!Number.isFinite(seconds)) return "--:--";
const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
/** The same time in words, for aria-valuetext. */
function spoken(seconds: number): string {
if (!Number.isFinite(seconds)) return "unknown";
const total = Math.max(0, Math.floor(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
const parts = [m > 0 ? `${m} minute${m === 1 ? "" : "s"}` : "", `${s} second${s === 1 ? "" : "s"}`];
return parts.filter(Boolean).join(" ");
}
/** End of the buffered range that holds the playhead, or of the last one. */
function bufferedEnd(media: HTMLMediaElement): number {
const ranges = media.buffered;
for (let i = 0; i < ranges.length; i++) {
if (ranges.start(i) <= media.currentTime && media.currentTime <= ranges.end(i)) return ranges.end(i);
}
return ranges.length > 0 ? ranges.end(ranges.length - 1) : 0;
}
function assign<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) ref.current = value;
}
function SkipIcon({ seconds, back }: { seconds: number; back?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<g transform={back ? undefined : "matrix(-1 0 0 1 24 0)"}>
<path d="M5.2 8.5A8 8 0 1 1 4 12.5" />
<path d="M4.6 4.2l.6 4.3 4.3-.6" />
</g>
<text x="12" y="15.6" textAnchor="middle" fontSize="8" fontWeight="650" fill="currentColor" stroke="none" fontFamily="inherit">
{seconds}
</text>
</svg>
);
}
export function AudioPlayer({
title,
artist,
album,
artwork,
variant = "full",
skip = 15,
rates = [0.75, 1, 1.25, 1.5, 2],
label,
mediaSession = true,
className,
style,
ref,
...rest
}: AudioPlayerProps) {
const audio = useRef<HTMLAudioElement | null>(null);
const [state, setState] = useState<State>(START);
const meta = useRef({ title, artist, album, mediaSession, skip });
meta.current = { title, artist, album, mediaSession, skip };
useEffect(() => {
const media = audio.current;
if (!media) return;
const sync = () =>
setState({
playing: !media.paused,
time: media.currentTime,
duration: media.duration,
buffered: bufferedEnd(media),
volume: media.volume,
muted: media.muted,
rate: media.playbackRate,
waiting: !media.paused && media.readyState < 3,
failed: media.error !== null,
});
for (const name of EVENTS) media.addEventListener(name, sync);
sync();
return () => {
for (const name of EVENTS) media.removeEventListener(name, sync);
};
}, []);
// The system media controls follow whichever player started last.
useEffect(() => {
const media = audio.current;
if (!media || !("mediaSession" in navigator)) return;
const session = navigator.mediaSession;
function claim() {
const { title: t, artist: a, album: al, mediaSession: on, skip: step } = meta.current;
if (!on || !media) return;
if (typeof MediaMetadata !== "undefined") {
session.metadata = new MediaMetadata({
title: typeof t === "string" ? t : "",
artist: typeof a === "string" ? a : "",
album: al ?? "",
});
}
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
["play", () => void media.play().catch(() => {})],
["pause", () => media.pause()],
["seekbackward", (d) => (media.currentTime = Math.max(0, media.currentTime - (d.seekOffset ?? step)))],
["seekforward", (d) => (media.currentTime = Math.min(media.duration || 0, media.currentTime + (d.seekOffset ?? step)))],
["seekto", (d) => d.seekTime !== undefined && (media.currentTime = d.seekTime)],
];
for (const [action, handler] of handlers) {
try {
session.setActionHandler(action, handler);
} catch {
// An action this browser does not know.
}
}
}
media.addEventListener("play", claim);
return () => media.removeEventListener("play", claim);
}, []);
const known = Number.isFinite(state.duration) && state.duration > 0;
function toggle() {
const media = audio.current;
if (!media) return;
if (media.paused) void media.play().catch(() => {});
else media.pause();
}
function seekTo(seconds: number) {
const media = audio.current;
if (!media || !known) return;
media.currentTime = Math.min(Math.max(seconds, 0), state.duration);
}
function setVolume(next: number) {
const media = audio.current;
if (!media) return;
media.volume = Math.min(1, Math.max(0, Math.round(next * 100) / 100));
if (media.volume > 0) media.muted = false;
}
function toggleMute() {
const media = audio.current;
if (!media) return;
if (media.muted || media.volume === 0) {
media.muted = false;
if (media.volume === 0) media.volume = 0.5;
} else media.muted = true;
}
function cycleRate() {
const media = audio.current;
if (!media || rates.length === 0) return;
const at = rates.findIndex((r) => r === media.playbackRate);
media.playbackRate = rates[(at + 1) % rates.length] ?? 1;
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
const target = event.target;
const onButton = target instanceof HTMLButtonElement;
const range = target instanceof HTMLInputElement && target.type === "range" ? target : null;
const onVolume = range?.dataset.control === "volume";
const key = event.key;
if ((key === " " || key === "Enter") && onButton) return;
if (onVolume && key.startsWith("Arrow")) return;
if (range && (key === "Home" || key === "End" || key === "PageUp" || key === "PageDown")) return;
const media = audio.current;
if (!media) return;
if (key === " " || key === "k" || key === "K") toggle();
else if (key === "ArrowLeft") seekTo(media.currentTime - 5);
else if (key === "ArrowRight") seekTo(media.currentTime + 5);
else if (key === "j" || key === "J") seekTo(media.currentTime - skip);
else if (key === "l" || key === "L") seekTo(media.currentTime + skip);
else if (key === "ArrowUp") setVolume((media.muted ? 0 : media.volume) + 0.1);
else if (key === "ArrowDown") setVolume(media.volume - 0.1);
else if (key === "m" || key === "M") toggleMute();
else if (key === "Home") seekTo(0);
else if (key === "End") seekTo(state.duration);
else return;
event.preventDefault();
event.stopPropagation();
}
const played = known ? Math.min(1, state.time / state.duration) : 0;
const loaded = known ? Math.min(1, state.buffered / state.duration) : 0;
const silent = state.muted || state.volume === 0;
const compact = variant === "compact";
const name = label ?? (typeof title === "string" ? title : "Audio player");
const seek = (
<input
className="eq-audio-player-seek"
type="range"
min={0}
max={known ? state.duration : 1}
step="any"
value={known ? state.time : 0}
disabled={!known}
aria-label="Position"
aria-valuetext={known ? `${spoken(state.time)} of ${spoken(state.duration)}` : "Not loaded"}
style={{ "--_played": played, "--_loaded": loaded } as CSSProperties}
onChange={(event) => seekTo(event.currentTarget.valueAsNumber)}
/>
);
const playButton = (
<button
type="button"
className="eq-audio-player-play"
aria-label={state.playing ? "Pause" : "Play"}
data-waiting={state.waiting || undefined}
onClick={toggle}
>
<Icon name={state.playing ? "pause" : "play"} size={compact ? 16 : 18} />
</button>
);
const muteButton = (
<button
type="button"
className="eq-audio-player-button"
aria-label="Mute"
aria-pressed={silent}
onClick={toggleMute}
>
<Icon name={silent ? "volume-off" : "volume"} size={18} />
</button>
);
const text = (title != null || artist != null) && (
<div className="eq-audio-player-meta">
{title != null && <div className="eq-audio-player-title">{title}</div>}
{state.failed ? (
<div className="eq-audio-player-error" role="status">
This audio could not be played.
</div>
) : (
artist != null && !compact && <div className="eq-audio-player-artist">{artist}</div>
)}
</div>
);
return (
<div
role="group"
aria-label={name}
className={["eq-audio-player", className].filter(Boolean).join(" ")}
style={style}
data-variant={variant}
data-playing={state.playing || undefined}
onKeyDown={handleKeyDown}
>
<audio
preload="metadata"
{...rest}
ref={(node) => {
audio.current = node;
assign(ref, node);
}}
/>
{compact ? (
<>
{playButton}
<div className="eq-audio-player-main">
{text}
{seek}
</div>
<span className="eq-audio-player-time" aria-hidden="true">
{clock(state.time)} / {clock(state.duration)}
</span>
{muteButton}
</>
) : (
<>
{(artwork != null || text) && (
<div className="eq-audio-player-header">
{artwork != null && <div className="eq-audio-player-artwork">{artwork}</div>}
{text}
</div>
)}
<div className="eq-audio-player-timeline">
<span className="eq-audio-player-time" aria-hidden="true">
{clock(state.time)}
</span>
{seek}
<span className="eq-audio-player-time" aria-hidden="true">
{clock(state.duration)}
</span>
</div>
<div className="eq-audio-player-controls">
<button
type="button"
className="eq-audio-player-button"
data-control="skip"
aria-label={`Back ${skip} seconds`}
disabled={!known}
onClick={() => seekTo(state.time - skip)}
>
<SkipIcon seconds={skip} back />
</button>
{playButton}
<button
type="button"
className="eq-audio-player-button"
data-control="skip"
aria-label={`Forward ${skip} seconds`}
disabled={!known}
onClick={() => seekTo(state.time + skip)}
>
<SkipIcon seconds={skip} />
</button>
<span className="eq-audio-player-spacer" />
{rates.length > 1 && (
<button
type="button"
className="eq-audio-player-button"
data-control="rate"
aria-label={`Playback speed ${state.rate}×`}
onClick={cycleRate}
>
{state.rate}×
</button>
)}
{muteButton}
<input
className="eq-audio-player-volume"
type="range"
data-control="volume"
min={0}
max={1}
step={0.05}
value={silent ? 0 : state.volume}
aria-label="Volume"
aria-valuetext={`${Math.round((silent ? 0 : state.volume) * 100)}%`}
style={{ "--_played": silent ? 0 : state.volume } as CSSProperties}
onChange={(event) => setVolume(event.currentTarget.valueAsNumber)}
/>
</div>
</>
)}
</div>
);
}components/equip/audio-player.css343 lines
@layer equip {
.eq-audio-player {
box-sizing: border-box;
display: grid;
/* A size container has no width of its own, so it fills its box unless given one. */
width: 100%;
gap: var(--eq-space-1);
min-width: 0;
padding: var(--eq-space-2) var(--eq-space-3);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.35 var(--eq-font);
container-type: inline-size;
}
.eq-audio-player[data-variant="compact"] {
display: flex;
align-items: center;
gap: var(--eq-space-2);
padding: var(--eq-space-2);
border-radius: var(--eq-radius-m);
}
.eq-audio-player-header {
display: flex;
align-items: center;
gap: var(--eq-space-3);
min-width: 0;
margin-block: 2px var(--eq-space-1);
margin-bottom: var(--eq-space-1);
}
.eq-audio-player-artwork {
flex: none;
width: 44px;
height: 44px;
overflow: hidden;
border-radius: var(--eq-radius-m);
background: var(--eq-tint-strong);
}
.eq-audio-player-artwork > :is(img, svg, picture, canvas) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.eq-audio-player-meta {
display: grid;
min-width: 0;
}
.eq-audio-player-title,
.eq-audio-player-artist,
.eq-audio-player-error {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.eq-audio-player-title {
font-weight: var(--eq-weight-strong);
font-size: var(--eq-text-s);
}
.eq-audio-player-artist {
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-audio-player-error {
color: var(--eq-danger);
font-size: var(--eq-text-s);
}
.eq-audio-player[data-variant="compact"] .eq-audio-player-title {
font-size: var(--eq-text-xs);
}
.eq-audio-player-main {
display: grid;
flex: 1;
min-width: 0;
}
.eq-audio-player-timeline {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-audio-player-time {
flex: none;
min-width: 4ch;
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-audio-player-timeline .eq-audio-player-time:last-child {
text-align: end;
}
.eq-audio-player-controls {
display: flex;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-audio-player-spacer {
flex: 1;
}
/* Buttons: square ghosts, and a round solid play button. */
.eq-audio-player-button,
.eq-audio-player-play {
box-sizing: border-box;
display: inline-grid;
flex: none;
place-items: center;
min-width: var(--eq-h-s);
height: var(--eq-h-s);
margin: 0;
padding: 0 var(--eq-space-1);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-audio-player-button[data-control="rate"] {
min-width: 40px;
}
.eq-audio-player-play {
position: relative;
width: var(--eq-h-m);
height: var(--eq-h-m);
padding: 0;
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-audio-player[data-variant="compact"] .eq-audio-player-play {
width: var(--eq-h-s);
height: var(--eq-h-s);
min-width: 0;
}
/* Buffering: a ring turns round the play button until sound arrives. */
.eq-audio-player-play[data-waiting]::after {
content: "";
position: absolute;
inset: -3px;
border: 2px solid transparent;
border-top-color: var(--eq-ink-2);
border-radius: inherit;
animation: eq-audio-player-spin 800ms linear infinite;
}
@keyframes eq-audio-player-spin {
to {
rotate: 1turn;
}
}
@media (hover: hover) {
.eq-audio-player-button:hover:not(:disabled) {
background: var(--eq-tint);
}
.eq-audio-player-play:hover {
background: color-mix(in srgb, var(--eq-solid) 88%, var(--eq-solid-ink));
}
}
.eq-audio-player-button:active:not(:disabled),
.eq-audio-player-play:active {
scale: var(--eq-press);
}
.eq-audio-player-button:focus-visible,
.eq-audio-player-play:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-audio-player-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-audio-player-button[aria-pressed="true"] {
color: var(--eq-ink-2);
}
/* Range inputs: a 4px groove. The seek bar paints played, then buffered, then the rest. */
.eq-audio-player-seek,
.eq-audio-player-volume {
--_thumb: 12px;
--_track: 4px;
--_stop: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_played, 0));
--_load: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_loaded, 0));
appearance: none;
display: block;
flex: 1;
min-width: 0;
height: 24px;
margin: 0;
background: transparent;
cursor: pointer;
}
.eq-audio-player-volume {
flex: 0 1 80px;
}
.eq-audio-player-seek:focus-visible,
.eq-audio-player-volume:focus-visible {
outline: none;
}
.eq-audio-player-seek:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.eq-audio-player-seek::-webkit-slider-runnable-track,
.eq-audio-player-volume::-webkit-slider-runnable-track {
height: var(--_track);
border-radius: var(--eq-radius-full);
background:
linear-gradient(to right, var(--eq-solid) var(--_stop), transparent var(--_stop)),
linear-gradient(to right, var(--eq-tint-strong) var(--_load), transparent var(--_load)),
var(--eq-tint-strong);
}
.eq-audio-player-seek::-webkit-slider-runnable-track {
background:
linear-gradient(to right, var(--eq-solid) var(--_stop), transparent var(--_stop)),
linear-gradient(to right, color-mix(in srgb, var(--eq-ink) 30%, transparent) var(--_load), transparent var(--_load)),
var(--eq-tint-strong);
}
.eq-audio-player-seek::-webkit-slider-thumb,
.eq-audio-player-volume::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: var(--_thumb);
height: var(--_thumb);
margin-top: calc((var(--_track) - var(--_thumb)) / 2);
border: 2px solid var(--eq-solid);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-audio-player-seek:focus-visible::-webkit-slider-thumb,
.eq-audio-player-volume:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-audio-player-seek:active:not(:disabled)::-webkit-slider-thumb,
.eq-audio-player-volume:active::-webkit-slider-thumb {
scale: 1.25;
}
.eq-audio-player-seek::-moz-range-track,
.eq-audio-player-volume::-moz-range-track {
height: var(--_track);
border-radius: var(--eq-radius-full);
background: var(--eq-tint-strong);
}
.eq-audio-player-seek::-moz-range-track {
background:
linear-gradient(to right, color-mix(in srgb, var(--eq-ink) 30%, transparent) var(--_load), transparent var(--_load)),
var(--eq-tint-strong);
}
.eq-audio-player-seek::-moz-range-progress,
.eq-audio-player-volume::-moz-range-progress {
height: var(--_track);
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
}
.eq-audio-player-seek::-moz-range-thumb,
.eq-audio-player-volume::-moz-range-thumb {
box-sizing: border-box;
width: var(--_thumb);
height: var(--_thumb);
border: 2px solid var(--eq-solid);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-audio-player-seek:focus-visible::-moz-range-thumb,
.eq-audio-player-volume:focus-visible::-moz-range-thumb {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Narrow players keep the mute button and drop the volume groove; the arrows still set it. */
@container (max-width: 340px) {
.eq-audio-player-volume {
display: none;
}
}
@container (max-width: 250px) {
.eq-audio-player-time {
display: none;
}
.eq-audio-player[data-variant="full"] .eq-audio-player-time {
display: block;
}
}
/* A spinner still reports that something is happening, so it slows down rather than stops. */
@media (prefers-reduced-motion: reduce) {
.eq-audio-player-play[data-waiting]::after {
animation-duration: 2.4s;
}
}
}