Skip to content
UI Kit
Accent color

Audio player

Audio with a drawn waveform you can click, drag and step through, from a podcast card to a voice message.

One of 5 components in Media

3 variants

Variants

Podcast card

An episode with cover, waveform, skip buttons, time and speed. The played part of the waveform takes the accent.

Compact inline

One row for articles, lessons and lists. Play, waveform, time and speed.

Voice message

Chat bubbles for incoming and own messages. Speed appears once the message plays.

Details

What Audio player brings along, read from the registry and the source.

Category
Media
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/audio-player.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 13 texts of its own
Studio
Adjust in code

Props

The AudioPlayerProps interface from components/ui/audio-player.tsx, with the comments from the code.

  • srcoptional
    string

    Audio file. Without it the player runs as a preview and simulates playback with a timer.

  • durationoptional
    number

    Length in seconds when there is no src (or before the metadata loads). Default 180.

  • variantoptional
    AudioPlayerVariant

    cardcompactvoice

    card: episode with artwork. compact: one row for articles and lists. voice: a chat bubble.

  • titleoptional
    string

    Episode or track title (card), also used for the accessible name

  • subtitleoptional
    string

    Show or artist above the title (card)

  • metaoptional
    React.ReactNode

    Small line under the title (card) or next to the time (voice), e.g. a date or "09:41"

  • artworkoptional
    React.ReactNode

    Cover artwork for the card, any node

  • peaksoptional
    number[]

    Real waveform levels from 0 to 1. Without them the bars come from the seed.

  • seedoptional
    number

    Seed for the drawn waveform, same seed same shape

  • barsoptional
    number

    Number of bars. Default: card 64, compact 40, voice 32.

  • initialTimeoptional
    number

    Start position in seconds

  • speedsoptional
    number[]

    Speeds the speed button cycles through. Default 1, 1.5, 2.

  • alignoptional
    'start' | 'end'

    voice: start for incoming messages (left, muted), end for your own (right, accent)

  • animateoptional
    boolean

    The waveform draws itself when it scrolls into view. Default true.

Built-in texts

Audio player writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 13 texts
playerEnglish Audio playerGerman Audioplayer
playEnglish PlayGerman Abspielen
pauseEnglish PauseGerman Pausieren
seekEnglish PositionGerman Position
speedEnglish Playback speedGerman Wiedergabegeschwindigkeit
backEnglish Back 15 secondsGerman 15 Sekunden zurück
forwardEnglish Forward 15 secondsGerman 15 Sekunden vor
ofEnglish ofGerman von
errorEnglish The audio could not be loaded.German Die Audiodatei ließ sich nicht laden.
minuteEnglish minuteGerman Minute
minutesEnglish minutesGerman Minuten
secondEnglish secondGerman Sekunde
secondsEnglish secondsGerman Sekunden

Accessibility

Read from the source of the component.

  • Audio player handles the keys Home, End and Space itself.
  • Sets aria-label, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles alert, group and slider.
  • Announces changes through a live region without moving focus.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 561 lines
components/ui/audio-player.tsx
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';

const TEXTS = {
  en: {
    player: 'Audio player',
    play: 'Play',
    pause: 'Pause',
    seek: 'Position',
    speed: 'Playback speed',
    back: 'Back 15 seconds',
    forward: 'Forward 15 seconds',
    of: 'of',
    error: 'The audio could not be loaded.',
    minute: 'minute',
    minutes: 'minutes',
    second: 'second',
    seconds: 'seconds',
  },
  de: {
    player: 'Audioplayer',
    play: 'Abspielen',
    pause: 'Pausieren',
    seek: 'Position',
    speed: 'Wiedergabegeschwindigkeit',
    back: '15 Sekunden zurück',
    forward: '15 Sekunden vor',
    of: 'von',
    error: 'Die Audiodatei ließ sich nicht laden.',
    minute: 'Minute',
    minutes: 'Minuten',
    second: 'Sekunde',
    seconds: 'Sekunden',
  },
};
type Texts = (typeof TEXTS)['en'];

const intlLocale = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));

/** 65 -> "1:05", 3725 -> "1:02:05" */
function formatTime(seconds: number) {
  const s = Math.max(0, Math.floor(seconds || 0));
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const ss = String(s % 60).padStart(2, '0');
  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
}

function spokenTime(seconds: number, t: Texts) {
  const s = Math.max(0, Math.floor(seconds || 0));
  const m = Math.floor(s / 60);
  const r = s % 60;
  const parts: string[] = [];
  if (m) parts.push(`${m} ${m === 1 ? t.minute : t.minutes}`);
  if (r || !m) parts.push(`${r} ${r === 1 ? t.second : t.seconds}`);
  return parts.join(' ');
}

/* ------------------------------------------------------------------ */
/* Motion pieces                                                       */
/* ------------------------------------------------------------------ */

const CSS = `
@keyframes kitfx-audio-grow{from{transform:scaleY(.06);opacity:.4}}
.kitfx-audio[data-anim=wait] .kitfx-audio-bar{transform:scaleY(.06);opacity:.4}
.kitfx-audio[data-anim=run] .kitfx-audio-bar{animation:kitfx-audio-grow .6s var(--ease-kit) both;animation-delay:var(--d,0ms)}
.kitfx-audio-morph{transition:d .32s var(--ease-kit)}
@keyframes kitfx-audio-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitfx-audio[data-pending] .kitfx-audio-bar{animation:kitfx-audio-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitfx-audio .kitfx-audio-bar{animation:none!important;transform:none!important;opacity:1!important}.kitfx-audio-morph{transition:none}}
`;

/* Play and pause as two shapes each with the same points, so one morphs into the other */
const SHAPES = {
  play: ['M7 4.5L13.25 8.25L13.25 15.75L7 19.5Z', 'M13.25 8.25L19.5 12L19.5 12L13.25 15.75Z'],
  pause: ['M6 5L10 5L10 19L6 19Z', 'M14 5L18 5L18 19L14 19Z'],
};

function PlayPause({ playing, size }: { playing: boolean; size: number }) {
  const d = playing ? SHAPES.pause : SHAPES.play;
  return (
    <svg aria-hidden width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round">
      <path className="kitfx-audio-morph" d={d[0]} />
      <path className="kitfx-audio-morph" d={d[1]} />
    </svg>
  );
}

/**
 * The waveform draws itself when it scrolls into view: 'final' on the server,
 * without JavaScript and with reduced motion, 'wait' until it is in view, then 'run'.
 */
function useEntrance<T extends Element>(enabled: boolean) {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'final' | 'wait' | 'run'>('final');
  const [pending, setPending] = useState(enabled);
  useEffect(() => {
    setPending(false);
    const el = ref.current;
    if (!enabled || !el || prefersReducedMotion() || typeof IntersectionObserver === 'undefined') return;
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e.isIntersecting) return;
        setState('run');
        io.disconnect();
      },
      { rootMargin: '0px 0px -6% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [enabled]);
  return [ref, state === 'final' ? undefined : state, pending] as const;
}

/* ------------------------------------------------------------------ */
/* Waveform data                                                       */
/* ------------------------------------------------------------------ */

function seeded(seed: number) {
  let a = (seed * 2654435761) >>> 0 || 1;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let x = a;
    x = Math.imul(x ^ (x >>> 15), x | 1);
    x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}

/** Speech-like levels from a seed: syllables, short pauses, a softer start and end. */
function wavePeaks(seed: number, count: number) {
  const rand = seeded(seed);
  const raw = Array.from({ length: count }, (_, i) => {
    // two slow waves make phrases, the random part makes syllables
    const phrase = 0.5 + 0.5 * Math.sin(i * 0.31 + seed) * Math.cos(i * 0.093 + seed * 1.7);
    const pause = rand() < 0.06 ? 0.2 : 1;
    return (0.15 + rand() ** 1.4 * 0.85) * (0.2 + phrase * 0.8) * pause;
  });
  return raw.map((v, i) => {
    const near = (raw[i - 1] ?? v) * 0.25 + v * 0.5 + (raw[i + 1] ?? v) * 0.25;
    const edge = Math.min(1, (i + 2) / 5, (count - i + 1) / 5);
    return near * edge;
  }).map((v, _, all) => round(clamp(0.1 + (v / Math.max(...all)) * 0.9, 0.1, 1), 2));
}

/** Resample given peaks (0 to 1) to a number of bars, taking the loudest value in each slice. */
function resample(peaks: number[], count: number) {
  if (!peaks.length) return Array(count).fill(0.1);
  return Array.from({ length: count }, (_, i) => {
    const a = Math.floor((i / count) * peaks.length);
    const b = Math.max(a + 1, Math.floor(((i + 1) / count) * peaks.length));
    return round(clamp(Math.max(...peaks.slice(a, b)), 0.08, 1), 2);
  });
}

/* ------------------------------------------------------------------ */
/* Player                                                              */
/* ------------------------------------------------------------------ */

export type AudioPlayerVariant = 'card' | 'compact' | 'voice';

export interface AudioPlayerProps {
  /** Audio file. Without it the player runs as a preview and simulates playback with a timer. */
  src?: string;
  /** Length in seconds when there is no src (or before the metadata loads). Default 180. */
  duration?: number;
  /** card: episode with artwork. compact: one row for articles and lists. voice: a chat bubble. */
  variant?: AudioPlayerVariant;
  /** Episode or track title (card), also used for the accessible name */
  title?: string;
  /** Show or artist above the title (card) */
  subtitle?: string;
  /** Small line under the title (card) or next to the time (voice), e.g. a date or "09:41" */
  meta?: React.ReactNode;
  /** Cover artwork for the card, any node */
  artwork?: React.ReactNode;
  /** Real waveform levels from 0 to 1. Without them the bars come from the seed. */
  peaks?: number[];
  /** Seed for the drawn waveform, same seed same shape */
  seed?: number;
  /** Number of bars. Default: card 64, compact 40, voice 32. */
  bars?: number;
  /** Start position in seconds */
  initialTime?: number;
  /** Speeds the speed button cycles through. Default 1, 1.5, 2. */
  speeds?: number[];
  /** voice: start for incoming messages (left, muted), end for your own (right, accent) */
  align?: 'start' | 'end';
  /** The waveform draws itself when it scrolls into view. Default true. */
  animate?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

/**
 * An audio player with a drawn waveform you can click, drag and step through with the keyboard.
 *   <AudioPlayer variant="card" src="/episode-42.mp3" title="…" subtitle="…" artwork={<img … />} />
 *   <AudioPlayer variant="voice" src="/note.m4a" align="end" meta="09:41" />
 */
export function AudioPlayer({
  src,
  duration: durationProp = 180,
  variant = 'card',
  title,
  subtitle,
  meta,
  artwork,
  peaks,
  seed = 1,
  bars,
  initialTime = 0,
  speeds = [1, 1.5, 2],
  align = 'start',
  animate = true,
  labels,
  className,
}: AudioPlayerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const id = useId();
  const audioRef = useRef<HTMLAudioElement>(null);
  const waveRef = useRef<HTMLDivElement>(null);
  const [rootRef, anim, pending] = useEntrance<HTMLDivElement>(animate);

  const [playing, setPlaying] = useState(false);
  const [started, setStarted] = useState(initialTime > 0);
  const [time, setTime] = useState(initialTime);
  const [duration, setDuration] = useState(durationProp);
  const [rateIndex, setRateIndex] = useState(0);
  const [failed, setFailed] = useState(false);
  const [drag, setDrag] = useState(false);
  const [hover, setHover] = useState<number | null>(null);
  const timeRef = useRef(initialTime);
  const rateRef = useRef(speeds[0] ?? 1);
  const real = Boolean(src) && !failed;
  const rate = speeds[rateIndex] ?? 1;
  rateRef.current = rate;

  const count = bars ?? (variant === 'card' ? 64 : variant === 'compact' ? 40 : 32);
  const levels = useMemo(() => (peaks ? resample(peaks, count) : wavePeaks(seed, count)), [peaks, seed, count]);

  /* Only one kit player plays at a time */
  const pauseRef = useRef<() => void>(() => {});
  useEffect(() => {
    pauseRef.current = pause;
  });
  useEffect(() => {
    const onOther = (e: Event) => {
      if ((e as CustomEvent<string>).detail !== id) pauseRef.current();
    };
    window.addEventListener('kit-media-play', onOther);
    return () => window.removeEventListener('kit-media-play', onOther);
  }, [id]);

  /* Preview mode: a clock instead of a file */
  useEffect(() => {
    if (real || !playing) return;
    let raf = 0;
    let last = performance.now();
    let acc = 0;
    const tick = (now: number) => {
      const dt = Math.min(0.25, (now - last) / 1000) * rateRef.current;
      last = now;
      acc += dt;
      const next = Math.min(duration, timeRef.current + dt);
      timeRef.current = next;
      if (next >= duration) {
        setTime(next);
        setPlaying(false);
        return;
      }
      if (acc > 1 / 30) {
        acc = 0;
        setTime(next);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [real, playing, duration]);

  useEffect(() => {
    if (audioRef.current) audioRef.current.playbackRate = rate;
  }, [rate, real]);

  function play() {
    window.dispatchEvent(new CustomEvent('kit-media-play', { detail: id }));
    setStarted(true);
    if (timeRef.current >= duration - 0.05) seek(0);
    const a = audioRef.current;
    if (real && a) a.play().catch(() => setPlaying(false));
    else setPlaying(true);
  }
  function pause() {
    if (real && audioRef.current) audioRef.current.pause();
    setPlaying(false);
  }
  const toggle = () => (playing ? pause() : play());
  function seek(to: number) {
    const next = clamp(to, 0, duration);
    timeRef.current = next;
    setTime(next);
    setStarted(true);
    const a = audioRef.current;
    if (real && a && Number.isFinite(a.duration)) a.currentTime = next;
  }

  const fractionAt = (clientX: number) => {
    const r = waveRef.current!.getBoundingClientRect();
    return r.width ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
  };
  const step = variant === 'voice' ? 2 : 5;
  const onWaveKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const d = { ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step, PageDown: -duration / 10, PageUp: duration / 10 }[e.key];
    if (d !== undefined) seek(timeRef.current + d);
    else if (e.key === 'Home') seek(0);
    else if (e.key === 'End') seek(duration);
    else if (e.key === ' ' || e.key === 'k') toggle();
    else return;
    e.preventDefault();
  };

  const progress = duration ? clamp(time / duration, 0, 1) : 0;
  const speedText = `${new Intl.NumberFormat(intlLocale(locale), { maximumFractionDigits: 2 }).format(rate)}×`;
  const own = variant === 'voice' && align === 'end';

  /* Bars: base, hover preview and progress on top, clipped from the right */
  // Bars rise from the middle line one after another, left to right
  const barRow = (tone: string, drawn = true) => (
    <span className={cn('absolute inset-0 flex items-center', variant === 'card' ? 'gap-[2px] sm:gap-[3px]' : variant === 'compact' ? 'gap-[2px] max-sm:[&>span:nth-child(even)]:hidden' : 'gap-[2px]')}>
      {levels.map((v, i) => (
        <span
          key={i}
          className={cn('min-w-px flex-1 rounded-full', tone, drawn && 'kitfx-audio-bar')}
          style={{ height: `${round(v * 100)}%`, ...(drawn ? ({ '--d': `${round((i / count) * 640, 0)}ms` } as React.CSSProperties) : {}) }}
        />
      ))}
    </span>
  );
  const baseTone = own ? 'bg-[color-mix(in_oklch,var(--primary-foreground)_38%,transparent)]' : 'bg-[color-mix(in_oklch,var(--foreground)_17%,transparent)]';
  const hoverTone = own ? 'bg-[color-mix(in_oklch,var(--primary-foreground)_62%,transparent)]' : 'bg-[color-mix(in_oklab,var(--primary)_38%,transparent)]';
  const doneTone = own ? 'bg-primary-foreground' : 'bg-primary';

  const wave = (
    <div
      ref={waveRef}
      role="slider"
      tabIndex={0}
      aria-label={title ? `${t.seek}, ${title}` : t.seek}
      aria-valuemin={0}
      aria-valuemax={Math.floor(duration)}
      aria-valuenow={Math.floor(time)}
      aria-valuetext={`${spokenTime(time, t)} ${t.of} ${spokenTime(duration, t)}`}
      onKeyDown={onWaveKey}
      onPointerDown={(e) => {
        if (e.button !== 0) return;
        e.currentTarget.setPointerCapture(e.pointerId);
        setDrag(true);
        seek(fractionAt(e.clientX) * duration);
      }}
      onPointerMove={(e) => {
        if (e.pointerType === 'mouse') setHover(fractionAt(e.clientX));
        if (drag) seek(fractionAt(e.clientX) * duration);
      }}
      onPointerUp={() => setDrag(false)}
      onPointerCancel={() => setDrag(false)}
      onPointerLeave={() => setHover(null)}
      className={cn(
        'relative min-w-0 flex-1 cursor-pointer touch-none rounded-md outline-offset-4',
        variant === 'card' ? 'h-14' : variant === 'compact' ? 'h-11' : 'h-9',
      )}
    >
      {barRow(baseTone)}
      {hover !== null && (
        <span aria-hidden className="absolute inset-0" style={{ clipPath: `inset(0 ${round(100 - hover * 100)}% 0 0)` }}>
          {barRow(hoverTone, false)}
        </span>
      )}
      {/* The played part fills in the accent and glides between the updates of the clock */}
      <span aria-hidden className={cn('absolute inset-0', !drag && 'transition-[clip-path] duration-200 ease-linear motion-reduce:transition-none')} style={{ clipPath: `inset(0 ${round(100 - progress * 100, 3)}% 0 0)` }}>
        {barRow(doneTone)}
      </span>
    </div>
  );

  const playIcon = (size: number) => <PlayPause playing={playing} size={size} />;
  const motion = <style dangerouslySetInnerHTML={{ __html: CSS }} />;

  const speedButton = (extra?: string) => (
    <button
      type="button"
      onClick={() => setRateIndex((i) => (i + 1) % speeds.length)}
      aria-label={`${t.speed} ${speedText}`}
      className={cn(
        'relative inline-flex h-11 min-w-11 shrink-0 items-center justify-center rounded-md px-2 text-[12px] font-semibold tabular-nums transition-colors duration-150 sm:h-8 sm:min-w-10 sm:after:absolute sm:after:-inset-1.5 sm:after:content-[""]',
        own
          ? 'text-primary-foreground hover:bg-[color-mix(in_oklch,var(--primary-foreground)_14%,transparent)]'
          : 'border border-border text-muted-foreground hover:bg-accent hover:text-foreground',
        extra,
      )}
    >
      {speedText}
    </button>
  );

  const skipButton = (dir: -1 | 1) => (
    <button
      type="button"
      onClick={() => seek(timeRef.current + dir * 15)}
      aria-label={dir < 0 ? t.back : t.forward}
      className="relative grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
    >
      <Icon name={dir > 0 ? 'rotate-cw' : 'rotate-ccw'} size={30} strokeWidth={1.35} />
      <span aria-hidden className="absolute inset-0 grid place-items-center pt-[1px] text-[9px] font-semibold tracking-[-0.02em] tabular-nums">15</span>
    </button>
  );

  const audio = src && !failed && (
    <audio
      ref={audioRef}
      src={src}
      preload="metadata"
      onLoadedMetadata={(e) => {
        const d = e.currentTarget.duration;
        if (Number.isFinite(d)) setDuration(d);
        if (initialTime) e.currentTarget.currentTime = initialTime;
      }}
      onTimeUpdate={(e) => {
        timeRef.current = e.currentTarget.currentTime;
        setTime(e.currentTarget.currentTime);
      }}
      onPlay={() => setPlaying(true)}
      onPause={() => setPlaying(false)}
      onEnded={() => setPlaying(false)}
      onError={() => {
        setFailed(true);
        setPlaying(false);
      }}
    />
  );
  const error = failed && (
    <p role="alert" className="mt-2 text-[12px] text-destructive">
      {t.error}
    </p>
  );

  /* ---------- Voice message bubble ---------- */
  if (variant === 'voice') {
    return (
      <div
        ref={rootRef}
        data-anim={anim}
        data-pending={pending ? '' : undefined}
        role="group"
        aria-label={title ?? t.player}
        className={cn(
          'kitfx-audio flex w-full max-w-[21rem] items-center gap-2.5 rounded-[20px] py-2 pl-2 pr-3',
          own
            ? 'rounded-br-md bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.14),var(--shadow-sm)]'
            : 'rounded-bl-md border border-border bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]',
          className,
        )}
      >
        {motion}
        {audio}
        <button
          type="button"
          onClick={toggle}
          aria-label={playing ? t.pause : t.play}
          className={cn(
            'grid size-11 shrink-0 place-items-center rounded-full transition-transform duration-150 active:scale-95 motion-reduce:active:scale-100',
            own ? 'bg-primary-foreground text-primary' : 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18)]',
          )}
        >
          {playIcon(16)}
        </button>
        <div className="min-w-0 flex-1">
          <div className="flex items-center">{wave}</div>
          <div className={cn('mt-0.5 flex items-center justify-between text-[11px] tabular-nums', own ? 'text-[color-mix(in_oklch,var(--primary-foreground)_72%,transparent)]' : 'text-muted-foreground')}>
            <span>{formatTime(started ? time : duration)}</span>
            {meta && <span className="inline-flex items-center gap-1">{meta}</span>}
          </div>
          {error}
        </div>
        {started && speedButton('h-8 min-w-10 sm:h-7 sm:min-w-9 text-[11px]')}
      </div>
    );
  }

  /* ---------- Compact row ---------- */
  if (variant === 'compact') {
    return (
      <div ref={rootRef} data-anim={anim} data-pending={pending ? '' : undefined} role="group" aria-label={title ? `${t.player}, ${title}` : t.player} className={cn('kitfx-audio w-full', className)}>
        {motion}
        {audio}
        <div className="flex w-full items-center gap-3">
          <button
            type="button"
            onClick={toggle}
            aria-label={playing ? t.pause : t.play}
            className="grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-transform duration-150 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] active:scale-95 motion-reduce:active:scale-100"
          >
            {playIcon(16)}
          </button>
          {wave}
          <span className="w-10 shrink-0 text-right text-[12px] font-medium tabular-nums text-muted-foreground">{formatTime(started ? time : duration)}</span>
          {speedButton()}
        </div>
        {error}
      </div>
    );
  }

  /* ---------- Card: episode with artwork ---------- */
  return (
    <article
      ref={rootRef}
      data-anim={anim}
      data-pending={pending ? '' : undefined}
      aria-label={title ? `${t.player}, ${title}` : t.player}
      className={cn('kitfx-audio w-full rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-[var(--highlight),var(--shadow-md)] sm:p-6', className)}
    >
      {motion}
      {audio}
      <div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-5 sm:gap-x-6">
        {artwork && <div data-clip-ok className="size-20 overflow-hidden rounded-xl border border-border shadow-[var(--shadow-sm)] sm:row-span-2 sm:size-40 [&>*]:size-full">{artwork}</div>}
        <div className={cn('min-w-0 sm:self-end', !artwork && 'col-span-2')}>
          {subtitle && <p className="truncate text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{subtitle}</p>}
          {title && <h3 className="mt-1 line-clamp-3 text-balance text-[17px] font-semibold leading-snug tracking-[-0.015em] sm:text-h3">{title}</h3>}
          {meta && <p className="mt-1 text-[13px] text-muted-foreground">{meta}</p>}
        </div>
        <div className={cn('col-span-2 min-w-0 sm:self-start', artwork && 'sm:col-span-1 sm:col-start-2')}>
          <div className="flex">{wave}</div>
          <div className="mt-3 flex items-center gap-1">
            <button
              type="button"
              onClick={toggle}
              aria-label={playing ? t.pause : t.play}
              className="mr-1 grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-transform duration-150 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] active:scale-95 motion-reduce:active:scale-100"
            >
              {playIcon(17)}
            </button>
            {skipButton(-1)}
            {skipButton(1)}
            <span className="ml-auto mr-2 whitespace-nowrap text-[12px] font-medium tabular-nums text-muted-foreground">
              <span className="text-foreground">{formatTime(time)}</span>
              <span className="text-subtle-foreground"> / {formatTime(duration)}</span>
            </span>
            {speedButton()}
          </div>
          {error}
        </div>
      </div>
    </article>
  );
}

Frequent questions

How do I install Audio player?

With aiw add @ai-website/audio-player. The command puts components/ui/audio-player.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Audio player have?

3 variants, Podcast card, Compact inline and Voice message. You pick one with the variant prop, for example variant="compact".

Can I translate the texts of Audio player?

Yes. Audio player carries 13 texts of its own in English and German, such as “Audio player”, “Play” and “Pause”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Audio player?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Audio player with aiw_components and install it with aiw_add @ai-website/audio-player. Name the variant in your prompt, for example variant="card", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard