Jump to a component
Core · Media
AudioPlayer
A voice line or sound clip: a round play button, a Waveform you can click, drag or arrow through to seek, and the time. Use it wherever a character's voice or an audio take is heard. Listening only: voice settings live in casting, never next to the player.
Live, from @anicanon/sakuga 2.0.0
Sakuga for iOS and Android is coming
iOS
Coming soon
AudioPlayer for iOS is in the roughs. The tokens it will read are the ones on this page, the same on every platform.
Android
Coming soon
AudioPlayer for Android is in the roughs. The tokens it will read are the ones on this page, the same on every platform.
- Use it for
- Hearing a voice line or an audio take: a round play button, a waveform to seek through, and the time.
- Not for
- A sound drawn as bars alone, such as a track in the cutWaveform
- Choosing a voice to castVoiceTiles
- A sound's level and muteVolumeControl
- The playhead of an episode or a shortScrubber
Guidelines
The waveform is the core Waveform: bars 2px wide with 2px gaps, never thicker, as many as fit. The played colour sweeps through them linearly as value advances; dragging seeks with no easing. The play button sinks on press and its icon swaps in.
labelnames the sound for screen readers when notitleshows: the play button says "Play" and the waveform "Position in" with it.
import { useEffect, useRef, useState } from 'react';
import { AudioPlayer, Button } from '@anicanon/sakuga/ui';
function usePlay(start, len) {
const [value, setValue] = useState(start);
const [playing, setPlaying] = useState(false);
const t = useRef(start);
useEffect(() => {
if (!playing) return undefined;
let raf;
let last = performance.now();
function tick(now) {
t.current = Math.min(len, t.current + (now - last) / 1000);
last = now;
setValue(t.current);
if (t.current >= len) {
setPlaying(false);
return;
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [playing]);
return {
value: value,
playing: playing,
onToggle: (on) => {
if (on && t.current >= len) {
t.current = 0;
setValue(0);
}
setPlaying(on);
},
onSeek: (s) => {
t.current = s;
setValue(s);
},
};
}
export default function AudioPlayerExample() {
const a = usePlay(1.6, 4);
const b = usePlay(0.8, 4);
const c = usePlay(2.4, 6);
return (
<>
<AudioPlayer
{...Object.assign(
{
title: 'His voice',
subtitle: 'Cast as Shounen Hero · warm, a little husky',
duration: 4,
},
a,
)}
>
<Button variant="ghost" size="sm" icon="mic">
Recast
</Button>
</AudioPlayer>
<AudioPlayer
{...Object.assign({ size: 'sm', duration: 4, seed: 3 }, b)}
/>
<img
src="/art/eba-cinema.webp"
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(0deg,var(--bg-scrim-strong),transparent 85%)',
}}
/>
<AudioPlayer
{...Object.assign(
{
variant: 'media',
title: 'The theme',
subtitle: 'Over the opening shot',
duration: 6,
seed: 5,
},
c,
)}
/>
</>
);
}PropsAudioPlayerProps
durationrequirednumbertitlestringsubtitlestringlabelstringvaluenumberplayingbooleanonToggle(playing: boolean) => voidonSeek(seconds: number) => voidpeaksnumber[]seed.barsnumberseednumbersize'sm' | 'md'sm for cards and rows.variant'default' | 'media'media on dark art: the play button is white with a dark glyph, as Button media is, so the one action reads over the picture.classNamestringstyleCSSPropertieschildrenReactNodeTokens: --audio-play-bg, --audio-play-fg, --audio-wave, --audio-wave-played, --audio-time.
Each token reads a role, and the role lands on a value in each theme. Change the role and every component that reads it follows.
Colour
audio-play-media-bgAudioPlayer: the play button over art, white as Button media is.action-medianeutral-0#ffffffaction-medianeutral-0#ffffffaudio-play-media-fgAudioPlayer: the play glyph over art, dark on the white button.text-on-action-medianeutral-925#0b0a10text-on-action-medianeutral-925#0b0a10Space and shape
audio-play-bgaction-primaryaudio-play-fgtext-inverseaudio-waveaudio-wave-playedaccentaudio-timetext-mutedaudio-titletext-primaryaudio-subtext-mutedaudio-gapgap-lgaudio-play-sizeaudio-play-active-scalescale-press-smaudio-play-size-smaudio-titled-play-sizeaudio-play-focus-outline-offsetfocus-ring-offsetaudio-main-gapgap-smaudio-head-gapgap-lgaudio-actions-gapgap-xsaudio-row-gapgap-lgaudio-play-icon-size-smicon-size-xsaudio-play-icon-sizeicon-size-smaudio-forced-edge-widthline-hairlineType
audio-font-familyfont-sansaudio-title-fonttext-labelaudio-sub-fonttext-smallaudio-time-fonttext-small-strongMotion
audio-play-durationmotion-fastaudio-play-easingease-standardaudio-play-active-durationmotion-instantaudio-play-icon-swap-animationmotion-swapChecked automatically with axe-core 4.11.4 against WCAG 2.2 A and AA, on the live preview above, in light and dark, on 26 October 2026.
- Result
- No issues found, in light or in dark.
- By design
- Tab reaches the play button, announced "Play" or "Pause" (from
playingonce it is given) followed bylabel, elsetitle(or "audio"), then the title's actions (children), then, withonSeek, the waveform: a slider named "Position in" plus that name, where arrows move a second, Home and End jump to either end, and the value is spoken as "12 seconds of 40 seconds". The time ("0:12 / 0:40") reads as text; withoutonSeekthe waveform is hidden and can't be moved. - Not tested
- The automated check doesn't test keyboard use, focus order or what a screen reader says. The lines above are the design system's own account of them.