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
Variants
Details
What Audio player brings along, read from the registry and the source.
- Category
- Media
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AudioPlayerProps interface from components/ui/audio-player.tsx, with the comments from the code.
srcoptionalstringAudio file. Without it the player runs as a preview and simulates playback with a timer.
durationoptionalnumberLength in seconds when there is no src (or before the metadata loads). Default 180.
variantoptionalAudioPlayerVariantcardcompactvoice
card: episode with artwork. compact: one row for articles and lists. voice: a chat bubble.
titleoptionalstringEpisode or track title (card), also used for the accessible name
subtitleoptionalstringShow or artist above the title (card)
metaoptionalReact.ReactNodeSmall line under the title (card) or next to the time (voice), e.g. a date or "09:41"
artworkoptionalReact.ReactNodeCover artwork for the card, any node
peaksoptionalnumber[]Real waveform levels from 0 to 1. Without them the bars come from the seed.
seedoptionalnumberSeed for the drawn waveform, same seed same shape
barsoptionalnumberNumber of bars. Default: card 64, compact 40, voice 32.
initialTimeoptionalnumberStart position in seconds
speedsoptionalnumber[]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)
animateoptionalbooleanThe 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
| Key | English | German |
|---|---|---|
| player | English Audio player | German Audioplayer |
| play | English Play | German Abspielen |
| pause | English Pause | German Pausieren |
| seek | English Position | German Position |
| speed | English Playback speed | German Wiedergabegeschwindigkeit |
| back | English Back 15 seconds | German 15 Sekunden zurück |
| forward | English Forward 15 seconds | German 15 Sekunden vor |
| of | English of | German von |
| error | English The audio could not be loaded. | German Die Audiodatei ließ sich nicht laden. |
| minute | English minute | German Minute |
| minutes | English minutes | German Minuten |
| second | English second | German Sekunde |
| seconds | English seconds | German 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
'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.