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
Arpeggio in A
Made in your browser
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.

Episode 41: Designing for slow networks
Field notes
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 Raman
Voice note
Sent 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.

Interview with the night crew
Recording in progress
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

PropTypeDefaultWhat it does
titleReactNodenoneTrack name, in bold. A string also goes to the Media Session and names the player.
artistReactNodenoneWho made it, under the title. A string also goes to the Media Session.
albumstringnoneAlbum or show, for the Media Session only.
artworkReactNodenoneCover 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.
skipnumber15Seconds the skip buttons and J and L jump.
ratesnumber[][0.75, 1, 1.25, 1.5, 2]Speeds the speed button steps through. One rate or none hides it.
labelstringnoneNames the player group. Defaults to the title when it is a string, else "Audio player".
mediaSessionbooleantrueOn play, tells the system media controls what is playing and wires their buttons to this player.
...restComponentProps<"audio">noneEvery native audio attribute and handler, including src, preload, loop, ref and source children. className and style go to the wrapper.

Anatomy

root.eq-audio-playerA labelled group and the size container; data-variant is full or compact.
header.eq-audio-player-headerArtwork and text, full variant.
artwork.eq-audio-player-artwork
meta.eq-audio-player-metaTitle with artist or the error line.
title.eq-audio-player-title
artist.eq-audio-player-artist
error.eq-audio-player-errorReplaces the artist when the source fails.
main.eq-audio-player-mainCompact variant: title over the seek bar.
timeline.eq-audio-player-timelineElapsed time, seek bar and length.
time.eq-audio-player-time
seek.eq-audio-player-seekA native range. The groove shows played, then buffered, then the rest.
controls.eq-audio-player-controls
play.eq-audio-player-playRound, solid. A ring turns round it while buffering.
button.eq-audio-player-buttonSkip, speed and mute; data-control tells them apart.
spacer.eq-audio-player-spacer
volume.eq-audio-player-volumeA native range, hidden below 340px wide.

Keyboard

SpaceKPlays or pauses, from anywhere in the player except a button, where Space presses it.
ArrowLeftArrowRightSeeks back or forward 5 seconds. On the volume slider they change the volume instead.
JLSkips back or forward by the skip amount.
ArrowUpArrowDownRaises or lowers the volume by 10%.
MMutes or unmutes.
HomeEndJumps to the start or the end.
TabMoves 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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index