Audio player
Audio player plays a podcast episode from the bottom edge of the app: a compact bar that grows upward for speed, skips and a scrubbable waveform. Use it with episode cards on the Podcast tab.
Beta until design confirms the code-only hover and focus styles, the speed list, the slider keyboard steps and the narrow layout, and the player is tested with VoiceOver, NVDA and TalkBack on a real episode.
Preview
Change the controls to try every supported option. The docs ship no audio files, so this preview simulates playback with props; in an app you pass src.
import { AudioPlayer } from '@nuton/ui'
<AudioPlayer
title="Introduction to Design Principles"
src="/podcasts/design-principles-1.mp3"
subtitle="The Design of Everyday Things · Episode 2"
onClose={closePlayer}
/>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokensnpm install @nuton/ui @nuton/tokensyarn add @nuton/ui @nuton/tokensPeer dependencies: react and react-dom 18 or later. The icons come from @remixicon/react, a dependency of @nuton/ui. The speed menu uses the Dropdown menu (Radix).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)Usage
import { AudioPlayer } from '@nuton/ui'
export function EpisodePlayer({ episode, onClose }) {
return (
<AudioPlayer
title={episode.title}
src={episode.audioUrl}
onClose={onClose}
/>
)
}- Pass
srcand the player renders its own<audio>element and reads time, duration and state from it. UseaudioRefto reach the element. - Without
src, drive it with props:playing,currentTime,duration, and theonPlayingChange,onSeekandonPlaybackRateChangecallbacks. Use this for a shared or native player. - Show one player at a time, fixed near the bottom of the Podcast tab, and keep it open while people browse episodes.
- Use
EpisodeCardinside anEpisodeListfor the episodes. Each card's buttons include the episode title in their names. - Pass
onCloseto show the close button. Stop playback when the player closes.
Variants and sizes
The dock is the player. The earlier floating pill remains as layout="pill" for existing uses: it has two surfaces, and Figma names them after the page theme (Property 1=Light is the dark pill) while code names them after the pill itself.
Appearance
Dock (the default)
The dock is the player (layout="dock" is the default): it is the bar for the bottom edge of an app. It follows the theme (light, dark or the device setting): full width, square corners, always the near-black surface, and a thin line along its top. It is compact by default (cover, title, waveform, time, play, expand, close) and grows upward when expanded (the waveform full width with both times, speed, back and forward, play, next, minimize). It is one player with two layers of controls, so expanding and minimizing never interrupt playback. Put it where it docks (a flex child under the content, not an overlay) and the content above it simply gets shorter. The waveform is the seek bar: pointer, arrow, Page and Home/End keys, and a spoken value. Pass peaks for a real waveform; without it a shape that suggests speech is drawn.
<AudioPlayer
layout="dock"
title="Affordances and signifiers"
subtitle="The Design of Everyday Things · Episode 2"
src={url}
onNext={nextEpisode}
onClose={closePlayer}
/><AudioPlayer layout="dock" defaultExpanded title="Affordances and signifiers" src={url} onNext={nextEpisode} onClose={closePlayer} />Pill (earlier design, kept for existing uses)
The floating pill with the two surfaces below is the earlier design. New work uses the dock; the pill stays as layout="pill" only so existing screens keep working.
<AudioPlayer layout="pill" title="Introduction to Design Principles" src={url} onClose={closePlayer} /><AudioPlayer layout="pill" appearance="light" title="Introduction to Design Principles" src={url} onClose={closePlayer} />Episode card
1. Introduction to Design Principles
The core principles behind products, systems and experiences.
<EpisodeList aria-label="Episodes">
<EpisodeCard
number={1}
title="Introduction to Design Principles"
description="The core principles behind products, systems and experiences."
duration={5340}
downloadHref="/podcasts/design-1.mp3"
onPlay={playEpisode}
/>
</EpisodeList>Sizes
| Part | Size | Token or source |
|---|---|---|
| Player pill | Padding 17 / 40, gap 24, full radius, 1px border, large custom shadow. About 76 tall. | --nu-audio-player-pad-*, --nu-space-6, --nu-shadow-custom-shadows-large |
| Speed button | 40 tall, at least 40 wide, Label/Large 18/30 | --nu-type-label-large-* |
| Skip buttons | 40 × 40, radius 8, icon 28 (Figma 41.5 and 29.6, rounded) | --nu-audio-player-skip-size |
| Play button | 36 circle, icon 20 | --nu-audio-player-play-size |
| Seek track | 8 tall (6px rail, 8px thumb). The invisible slider over it is 24 tall. | --nu-audio-player-track-height, --nu-audio-player-hit-height |
| Title and times | Label/Medium 16/28; times Subheading/2X Small 11/12, gap 3 | --nu-type-label-medium-*, --nu-type-subheading-2x-small-* |
| Close button | 52 circle, icon 20, 12 from the pill (40 in the narrow layout) | --nu-audio-player-close-size, --nu-space-3 |
| Episode card | Padding 16, gap 8, radius 20, 1px border; play pill 36 tall; ring 36; download 36 | --nu-episode-card-* |
Values from the component CSS, which follows Figma's geometry (Control center 604:35881, episode cards 594:20678). When the player is narrower than 520px, title and slider move above the controls and the pill becomes a rounded card (code-only).
States and interactions
Episode states
Figma has three types. The status comes from progress unless you set status: nothing heard is new, part heard is in progress (time left and a ring), all heard is completed. Pass playing to the episode that is playing now.
1. Introduction to Design Principles
The core principles behind products, systems and experiences, and how good design shapes everyday life.
2. Colour and contrast
How colour carries meaning, and how to keep text readable for everyone.
3. Typography that teaches
Choosing type for long reading, headings and small labels.
<EpisodeCard number={1} title="…" duration={5340} /> // new: play icon, "1h 29m"
<EpisodeCard number={2} title="…" duration={5340} progress={1620} /> // in progress: rhythm icon, "1h 2m left", ring
<EpisodeCard number={3} title="…" duration={5340} progress={5340} /> // completed: reset icon, "1h 29m"Paused
Paused
Hover a control
Hover a control
Press Tab to focus
Press Tab to focus
Loading episode
Loading episode
State tokens
Set on .nu-audio-player and .nu-episode-card. Override them on a wrapper only for a documented exception.
| Token | Dark | Light |
|---|---|---|
--nu-audio-player-bg | Bg/Strong-950 | Bg/Soft-100 |
--nu-audio-player-fg | Text/White | Text/Strong-950 |
--nu-audio-player-meta | Text/Soft-400 | Text/Sub-600 |
--nu-audio-player-hover | Bg/Surface-800 (code-only) | Bg/Soft-200 (code-only) |
--nu-audio-player-play-bg | Bg/Soft-100 | Bg/Strong-950 |
--nu-audio-player-rail | State/Faded/Base (neutral/500) | Bg/Soft-200 |
--nu-audio-player-fill | Bg/Soft-100 | neutral/700 |
--nu-audio-player-thumb | neutral/700 | Bg/Surface-800 (neutral/800) |
--nu-episode-card-bg / -bg-hover | Bg/White / Bg/Weak-50 | |
--nu-episode-card-ring-track / -ring-fill | Bg/Soft-200 / Bg/Strong-950 (stroke 4, code-only) | |
Composition examples
Podcast tab
Episode cards start an episode; the player appears below and keeps playing while people scroll. Pressing the playing episode's button pauses it. Each episode resumes where it stopped.
Localised labels
Every string is a labels entry. The time strings are functions, so you can say times naturally in each language.
API reference
AudioPlayer props
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | Episode or track title. Also names the player group. |
src | string | — | Audio URL. Renders and drives an internal <audio>. Leave it out to drive the player with props. |
audioRef | Ref<HTMLAudioElement> | — | Ref to the internal <audio> (only with src). |
preload | 'none' | 'metadata' | 'auto' | 'metadata' | Native preload of the internal element. |
playing | boolean | — | Playing state (controlled). With src, the element is played or paused to match. |
defaultPlaying | boolean | false | Initial playing state (uncontrolled, no src). |
onPlayingChange | (playing: boolean) => void | — | Called when playback starts or stops, from the controls or the element. |
currentTime | number | — | Position in seconds (controlled, no src). |
defaultCurrentTime | number | 0 | Initial position. With src, applied when the metadata loads. |
onSeek | (seconds: number) => void | — | Called when people seek with the slider or the skip buttons. |
onTimeUpdate | (seconds: number) => void | — | Called as the element's position updates (only with src). Use it to save progress. |
duration | number | — | Length in seconds. Required without src; with src it comes from the metadata. |
playbackRate | number | — | Playback speed (controlled). |
defaultPlaybackRate | number | 1 | Initial speed (uncontrolled). |
onPlaybackRateChange | (rate: number) => void | — | Called with the speed chosen from the menu. |
playbackRates | readonly number[] | [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] | Speeds in the speed menu (code-only list). |
skipSeconds | number | 15 | Seconds moved by the back and forward buttons. 5, 10, 15 and 30 have matching icons. |
onClose | () => void | — | Shows the close button (both layouts) and is called when it is pressed. |
layout | 'dock' | 'pill' | 'dock' | The full-width, square bar for the bottom edge of an app (default), or the earlier floating pill, kept for existing uses. |
subtitle | ReactNode | — | Dock: the show or episode line under the title. |
artwork | ReactNode | — | Dock: the small cover, an image or a mark. Left out, there is no cover. |
peaks | readonly number[] | — | Dock: real waveform data (0 to 1 over the whole episode). Left out, a shape that suggests speech is drawn. |
expanded / defaultExpanded | boolean | false | Dock: the expanded bar (controlled or initial). The compact bar is the default. |
onExpandedChange | (expanded: boolean) => void | — | Dock: called when the expand or minimize button is pressed. |
onNext | () => void | — | Dock: shows the next-episode button in the expanded bar. |
appearance | 'dark' | 'light' | 'dark' | Pill surface: Bg/Strong-950 (Figma Light) or Bg/Soft-100 (Figma Dark). |
labels | Partial<AudioPlayerLabels> | AUDIO_PLAYER_LABELS | Overrides for every string: player, play, pause, back(s), forward(s), speed, rate(r), seek, position(current, duration), close, clock(s). |
menuContainer | HTMLElement | null | — | Element for the speed menu portal (default document.body). |
className | string | — | Class for the root .nu-audio-player. |
ref | Ref<HTMLDivElement> | — | Forwarded to the root. |
…rest | Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children' | 'onPlay' | 'onPause' | 'onTimeUpdate'> | — | Any other attribute, spread on the root. |
EpisodeCard props
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | Episode title, rendered as a heading. |
durationrequired | number | — | Length in seconds, shown on the play button ("1h 29m"). |
number | number | — | Episode number before the title ("1."). |
description | ReactNode | — | Short summary under the title. |
progress | number | 0 | Seconds heard. Sets the status and the ring. |
status | 'new' | 'in-progress' | 'completed' | from progress | Figma Type: Default, Played, Completed. |
playing | boolean | false | This episode is playing now; the button becomes Pause. |
onPlay | MouseEventHandler<HTMLButtonElement> | — | Called when the play button is pressed. |
downloadHref | string | — | Renders the download control as a link with download. |
onDownload | MouseEventHandler<HTMLButtonElement> | — | Renders the download control as a button (when there is no downloadHref). |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level of the title. |
labels | Partial<EpisodeCardLabels> | EPISODE_CARD_LABELS | play, resume, replay, pause, download, duration(s), remaining(s). |
ref | Ref<HTMLElement> | — | Forwarded to the <article>. |
…rest | Omit<HTMLAttributes<HTMLElement>, 'title'> | — | Any other attribute, spread on the article. |
EpisodeList props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | EpisodeCards. Each is wrapped in an <li>. |
aria-label | string | — | Name of the list, e.g. the podcast title. |
…rest | HTMLAttributes<HTMLOListElement> | — | Spread on the <ol>. |
Helpers
formatClockTime (“1:02:23”), formatSpokenTime (“1 hour 2 minutes 23 seconds”), formatShortDuration (“1h 29m”), getEpisodeStatus, AUDIO_PLAYER_LABELS, AUDIO_PLAYER_RATES and EPISODE_CARD_LABELS are exported for building your own labels.
Styling hooks
The player renders .nu-audio-player (with data-appearance and data-playing), __bar, __speed, __skip, __play, __title, __seek (a Slider with .nu-slider__* parts), __times and __close. Cards render .nu-episode-card (with data-status) in .nu-episode-list. Use the --nu-audio-player-* and --nu-episode-card-* tokens to override.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves through speed, back, play, forward, the slider and close, in that order. |
| Enter / Space | Activates the focused button. On the speed button, opens the menu; arrows move, Enter chooses, Esc closes. |
| → / ↑ | On the slider: forward 5 seconds. |
| ← / ↓ | On the slider: back 5 seconds. |
| Page Up / Page Down | On the slider: forward or back 1 minute. |
| Home / End | On the slider: start or end of the episode. |
Screen readers
The player is a group named “Audio player” plus the title. Every control is a native button with a name (“Back 15 seconds”, “Play” or “Pause”, “Playback speed 1x”). The seek control is a native range input announced as “Seek, slider, 20 minutes 23 seconds of 1 hour 2 minutes 23 seconds”. The visible times are hidden, so they aren't read twice. Episode buttons say the action, the title and the time (“Resume 2. Colour and contrast 1h 2m left”); the ring is decorative.
Focus
A 2px primary outline with a 2px gap on every control, and around the whole track for the slider. Visible on both surfaces. The speed menu returns focus to its button when it closes.
Contrast and colour
Titles are 17.9:1 (white on Strong-950) and 16.4:1 (Strong-950 on Soft-100). Times are 7.2:1 (Soft-400 on Strong-950) and 6.1:1 (Sub-600 on Soft-100). On the dark pill the rail is 4.2:1 and the fill 3.9:1 against it. On the light pill the rail is State/Faded/Base, 3.88:1 on Soft-100, and the Strong-950 fill is 4.2:1 against it. Figma's Soft-200 rail was 1.1:1. Playing state is shown by the icon, not colour.
Touch and pointer
Buttons are 36 to 52px. The slider's input is 24px tall over the 8px track, so it is easy to grab and drag. Hover styles apply only on hover-capable pointers. Transitions stop with reduced motion.
You must: give every player a meaningful title; stop playback when the player closes; never autoplay without a user action; and pass translated labels in other languages, including the spoken position text.
Figma mapping
| Figma property | Figma values | Code |
|---|---|---|
| Control center · Property 1 | Light · Dark | appearance="dark" · appearance="light" (named by the pill colour) |
| Speed text | “1x” | playbackRate, speed menu (code-only list) |
| Progress | Static bar | Seek slider (native range) |
| Close button | Always shown | Shown with onClose |
| Episode card · Type | Default · Played · Completed | status: new · in-progress · completed (or from progress) |
| Episode card · State | Default · Hover | CSS hover |
Guidelines and related components
Use the audio player for
- Podcast episodes and other long audio people listen to while they read.
- A single, persistent player shared by a list of episodes.
- Audio that has a transcript. Link it next to the player so deaf and hard-of-hearing learners get the same content (WCAG 1.2.1).
Don't use it for
- Short sounds or pronunciation clips. Use a Button with a play icon.
- Video. Use the browser's video controls or a video player.
Do and don't
2. Colour and contrast
2. Colour and contrast
1. Introduction
1. Introduction
2. Colour and contrast
2. Colour and contrast
2. Colour and contrast
Episode
Common mistakes
- Starting playback on page load. Wait for the user to press play.
- Keeping audio playing after the close button is pressed.
- Translating the button names but not
position, so the slider still speaks English times. - Passing both
srcandcurrentTime: withsrc, the position comes from the element.