- Waveform progress bar powered by
wavesurfer.js - Playlist with drag-and-drop reorder, repeat, shuffle
- Fully customizable β swap any sub-component, CSS variable theming, light & dark themes
- Compound slots β
AudioPlayer.Volume,AudioPlayer.Progress,AudioPlayer.PlayList, etc. for partial customization without losing the preset - Multi-instance playlist β render multiple players on the same page with isolated playlist drawers and fully independent audio state
- Accessible β WAI-ARIA patterns, full keyboard navigation, axe-tested
- TypeScript-first β typed props and hooks (
useAudioPlayer, sub-hooks) - SSR-friendly β works with Next.js App Router / Server Components
https://codesandbox.io/p/sandbox/basic-nfrpfq
or
https://stackblitz.com/edit/stackblitz-webcontainer-api-starter-k4uxhzjx?file=src%2FApp.tsx
npm install --save react-modern-audio-player- React 18.0.0 or higher
- react-dom 18.0.0 or higher
For React 16/17 projects, use v1.x of this library.
import AudioPlayer from "react-modern-audio-player";
const playList = [
{
name: "name",
writer: "writer",
img: "image.jpg",
src: "audio.mp3",
id: 1,
},
];
function Player() {
return <AudioPlayer playList={playList} />;
}This library includes the 'use client' directive and can be imported directly in Next.js App Router.
Server Component β render <AudioPlayer> with static props (no hooks, no compound components):
// app/page.tsx β Server Component, no 'use client' needed
import AudioPlayer from "react-modern-audio-player";
const playList = [
{
name: "track",
writer: "artist",
img: "cover.jpg",
src: "audio.mp3",
id: 1,
},
];
export default function Page() {
return <AudioPlayer playList={playList} activeUI={{ playButton: true }} />;
}Client Component β use useAudioPlayer hooks or AudioPlayer.CustomComponent:
"use client";
// app/player/page.tsx β Client Component required for hooks & compound pattern
import AudioPlayer, { useAudioPlayer } from "react-modern-audio-player";
function Controls() {
const { isPlaying, togglePlay } = useAudioPlayer();
return <button onClick={togglePlay}>{isPlaying ? "Pause" : "Play"}</button>;
}
export default function PlayerPage() {
return (
<AudioPlayer playList={playList}>
<AudioPlayer.CustomComponent id="controls">
<Controls />
</AudioPlayer.CustomComponent>
</AudioPlayer>
);
}Why
'use client'? The library's'use client'directive marks the client boundary β it allows Server Components to import and render<AudioPlayer>. However,useAudioPlayer()hooks andAudioPlayer.CustomComponentrequire client-side React features (state, context), so components using them must be Client Components.
| Category | Sections |
|---|---|
| Props | PlayList Β· InitialStates Β· ActiveUI Β· Placement Β· RootContainerProps |
| Override & Style | CustomIcons Β· CoverImgsCss Β· Theme mode Β· ID & Classnames |
| Player Hook API | useAudioPlayer Β· AudioPlayerControls Β· Sub-Hooks |
| Custom Component | Custom Component Β· Compound Slots |
| Accessibility | Keyboard support |
| Gotchas | Gotchas |
| Example | Example |
interface AudioPlayerProps {
playList: PlayList;
audioInitialState?: InitialStates;
audioRef?: React.MutableRefObject<HTMLAudioElement>;
activeUI?: ActiveUI;
customIcons?: CustomIcons;
coverImgsCss?: CoverImgsCss;
placement?: {
player?: PlayerPlacement;
playList?: PlayListPlacement;
interface?: InterfacePlacement;
volumeSlider?: VolumeSliderPlacement;
speedSelector?: SpeedSelectorPlacement;
};
rootContainerProps?: RootContainerProps;
colorScheme?: "light" | "dark";
}| Prop | Type | Default |
|---|---|---|
playList |
PlayList | [ ] |
audioInitialState |
InitialStates | isPlaying: false repeatType: "ALL" volume: 1 |
activeUI |
ActiveUI | playButton : true |
customIcons |
CustomIcons | undefined |
coverImgsCss |
CoverImgsCss | undefined |
placement |
Placement | playListPlacement : "bottom" interfacePlacement :DefaultInterfacePlacement |
rootContainerProps |
RootContainerProps | width: 100% position: 'static' className: rmap-player-provider |
colorScheme |
"light" | "dark" |
undefined (follows OS prefers-color-scheme) |
type PlayList = Array<AudioData>;
type AudioData = {
src: string;
id: number;
name?: string;
writer?: string;
img?: string;
description?: string | ReactNode;
customTrackInfo?: string | ReactNode;
};Passing playList={[]} renders the player in an empty state without crashing. This is useful while waiting for asynchronous track data:
function App() {
const [tracks, setTracks] = useState<PlayList>([]);
useEffect(() => {
fetchTracks().then(setTracks);
}, []);
// Safe β the player mounts with no audio source and activates once tracks arrive.
return <AudioPlayer playList={tracks} />;
}- When the playlist becomes empty after updates, playback stops and all time state resets.
- When
audioInitialState.curPlayIddoesn't match any track in the current list, the player falls back toplayList[0]automatically.
type InitialStates = Omit<
React.AudioHTMLAttributes<HTMLAudioElement>,
"autoPlay"
> & {
isPlaying?: boolean;
repeatType?: RepeatType;
volume?: number;
currentTime?: number;
duration?: number;
curPlayId: number;
playListExpanded?: boolean;
};
playListExpanded: trueopens the playlist drawer on mount. Consistent with the other fields onaudioInitialState, this is read once at mount and is not tracked in reducer state.
type ActiveUI = {
all: boolean;
playButton: boolean;
playList: PlayListUI;
prevNnext: boolean;
volume: boolean;
volumeSlider: boolean;
repeatType: boolean;
trackTime: boolean;
trackInfo: boolean;
artwork: boolean;
progress: ProgressUI;
playbackRate: boolean;
};
type ProgressUI = "waveform" | "bar" | false;
type PlayListUI = "sortable" | "unSortable" | false;type CustomIcons = {
play: ReactNode;
pause: ReactNode;
prev: ReactNode;
next: ReactNode;
repeatOne: ReactNode;
repeatAll: ReactNode;
repeatNone: ReactNode;
repeatShuffle: ReactNode;
volumeFull: ReactNode;
volumeHalf: ReactNode;
volumeMuted: ReactNode;
playList: ReactNode;
};interface CoverImgsCss {
artwork?: React.CSSProperties;
listThumbnail?: React.CSSProperties;
}type PlayerPlacement =
| "bottom"
| "top"
| "bottom-left"
| "bottom-right"
| "top-left"
| "top-right";
type VolumeSliderPlacement = "bottom" | "top" | "left" | "right";
type SpeedSelectorPlacement = "bottom" | "top" | "left" | "right";
type PlayListPlacement = "bottom" | "top";
type InterfacePlacement = {
templateArea?: InterfaceGridTemplateArea;
customComponentsArea?: InterfaceGridCustomComponentsArea<TMaxLength>;
itemCustomArea?: InterfaceGridItemArea;
};
type InterfacePlacementKey =
| Exclude<keyof ActiveUI, "all" | "prevNnext" | "trackTime">
| "trackTimeCurrent"
| "trackTimeDuration";
type InterfacePlacementValue = "row1-1" | "row1-2" | "row1-3" | "row1-4" | ... more ... | "row10-10"
/** if you apply custom components, values must be "row1-1" ~ any more */
type InterfaceGridTemplateArea = Record<InterfacePlacementKey,InterfacePlacementValue>;
type InterfaceGridCustomComponentsArea = Record<componentId,InterfacePlacementValue>;
type InterfaceGridItemArea = Partial<Record<InterfacePlacementKey, string>>;
/** example
* progress : 2-4
* repeatBtn : row1-4 / 2 / row1-4 / 10
*
* check MDN - grid area
* https://developer.mozilla.org/ko/docs/Web/CSS/grid-area
*/const defaultInterfacePlacement = {
templateArea: {
artwork: "row1-1",
trackInfo: "row1-2",
trackTimeCurrent: "row1-3",
trackTimeDuration: "row1-4",
progress: "row1-5",
repeatType: "row1-6",
volume: "row1-7",
playButton: "row1-8",
playList: "row1-9",
playbackRate: "row1-10",
},
};rootContainerProps accepts any standard HTMLAttributes<HTMLDivElement> (e.g. className, style, data-*). The root container always has the class rmap-player-provider applied automatically.
β οΈ Setting the native CSScolor-schemeproperty viarootContainerProps={{ style: { colorScheme: "dark" } }}will not toggle the player's theme. The library's theme is driven by theprefers-color-schememedia query and the[data-theme]attribute selector β use the top-levelcolorSchemeprop instead.
Dark mode is driven by
system-theme(prefers-color-scheme: dark) by default. To force a specific theme regardless of OS preference, pass the top-levelcolorScheme="light" | "dark"prop on<AudioPlayer>β this applies adata-themeattribute on.rmap-player-providerwhich overrides the media query. You can override any color by redefining the CSS variables below on.rmap-player-provider.
@media (prefers-color-scheme: dark) {
.rmap-player-provider {
--rm-audio-player-interface-container: #1e1e1e;
/* override other variables as needed */
}
}- rm-audio-player
Multi-instance note: when multiple
<AudioPlayer>instances share a page the rootidis duplicated across them. Playlist and audio state are still isolated per instance (each player has its own React provider tree and its own<audio>DOM node). If you need per-instance selectors, target via the class names below rather than the id.
- rmap-player-provider
.rmap-player-provider {
--rm-audio-player-text-color: #2c2c2c;
--rm-audio-player-shadow: 0 0 0;
--rm-audio-player-interface-container: #f5f5f5;
--rm-audio-player-volume-background: #ccc;
--rm-audio-player-volume-panel-background: #f2f2f2;
--rm-audio-player-volume-panel-border: #ccc;
--rm-audio-player-volume-thumb: #5c5c5c;
--rm-audio-player-volume-fill: rgba(0, 0, 0, 0.5);
--rm-audio-player-volume-track: #ababab;
--rm-audio-player-track-current-time: #0072f5;
--rm-audio-player-track-duration: #8c8c8c;
--rm-audio-player-progress-bar: #0072f5;
--rm-audio-player-progress-bar-background: #393939;
--rm-audio-player-waveform-cursor: #4b4b4b;
--rm-audio-player-waveform-background: var(
--rm-audio-player-progress-bar-background
);
--rm-audio-player-waveform-bar: var(--rm-audio-player-progress-bar);
--rm-audio-player-sortable-list: #eaeaea;
--rm-audio-player-sortable-list-button-active: #0072f5;
--rm-audio-player-selected-list-item-background: #b3b3b3;
}Control the player externally using the useAudioPlayer hook. Must be called inside AudioPlayerStateProvider (or AudioPlayer which wraps it).
import AudioPlayer, { useAudioPlayer } from "react-modern-audio-player";
function PlayerControls() {
const {
isPlaying,
currentTrack,
currentTime,
duration,
togglePlay,
next,
prev,
seek,
setVolume,
setTrack,
} = useAudioPlayer();
return (
<div>
<button onClick={togglePlay}>{isPlaying ? "Pause" : "Play"}</button>
<button onClick={prev}>Prev</button>
<button onClick={next}>Next</button>
<button onClick={() => seek(30)}>+30s</button>
<button onClick={() => setVolume(0.5)}>Volume 50%</button>
<button onClick={() => setTrack(1)}>Track 2</button>
<p>
{currentTrack?.name} β {currentTime.toFixed(0)}s / {duration.toFixed(0)}s
</p>
</div>
);
}
function App() {
const playList = [{ id: 1, src: "audio.mp3", name: "Track 1" }];
return (
<AudioPlayer playList={playList}>
<PlayerControls />
</AudioPlayer>
);
}| Property | Type | Description |
|---|---|---|
isPlaying |
boolean |
Current playback state |
volume |
number |
Current volume (0β1) |
currentTime |
number |
Elapsed time in seconds |
duration |
number |
Track duration in seconds |
repeatType |
RepeatType |
Current repeat mode |
muted |
boolean |
Whether audio is muted |
currentTrack |
AudioData | null |
Currently playing track |
currentIndex |
number |
Index in playlist |
playList |
PlayList |
Full playlist |
play() |
() => void |
Start playback |
pause() |
() => void |
Pause playback |
togglePlay() |
() => void |
Toggle play/pause |
next() |
() => void |
Skip to next track |
prev() |
() => void |
Skip to previous track |
seek(time) |
(time: number) => void |
Seek to time in seconds |
setVolume(vol) |
(volume: number) => void |
Set volume (0β1, clamped) |
toggleMute() |
() => void |
Toggle mute on/off |
setTrack(index) |
(index: number) => void |
Jump to playlist index |
playbackRate |
number |
Current playback rate (1 = normal). Default 1. |
setPlaybackRate(rate) |
(rate: number) => void |
Set playback rate. No clamping; browser enforces HTML5 bounds. |
useAudioPlayer subscribes to all context slices. For fine-grained re-render control, use domain-specific sub-hooks:
import {
useAudioPlayerPlayback,
useAudioPlayerTrack,
useAudioPlayerVolume,
useAudioPlayerTime,
useAudioPlayerElement,
} from "react-modern-audio-player";
// Only re-renders on play/pause and repeat type changes
function PlayButton() {
const { isPlaying, togglePlay } = useAudioPlayerPlayback();
return <button onClick={togglePlay}>{isPlaying ? "Pause" : "Play"}</button>;
}
// Only re-renders on time updates
function TimeDisplay() {
const { currentTime, duration } = useAudioPlayerTime();
return (
<span>
{currentTime.toFixed(0)}s / {duration.toFixed(0)}s
</span>
);
}| Hook | Returns |
|---|---|
useAudioPlayerPlayback |
{ isPlaying, repeatType, playbackRate, play, pause, togglePlay, setPlaybackRate } |
useAudioPlayerTrack |
{ currentPlayId, currentIndex, playList, currentTrack, setTrack, next, prev } |
useAudioPlayerVolume |
{ volume, muted, setVolume, toggleMute } |
useAudioPlayerTime |
{ currentTime, duration, seek } |
useAudioPlayerElement |
{ audioEl, waveformInst } (advanced) |
Components inside AudioPlayer can subscribe to only the state slice they need, avoiding unnecessary re-renders.
import {
usePlaybackContext, // curAudioState: { isPlaying, repeatType, volume, muted, isLoadedMetaData }
useTrackContext, // playList, curIdx, curPlayId
useUIContext, // activeUI, interfacePlacement, playListPlacement, playerPlacement, volumeSliderPlacement
useResourceContext, // elementRefs, customIcons, coverImgsCss
} from "react-modern-audio-player";
const MyComponent = () => {
const { curAudioState } = usePlaybackContext();
return <span>{curAudioState.isPlaying ? "Playing" : "Paused"}</span>;
};| Hook | Returns |
|---|---|
usePlaybackContext |
{ curAudioState: AudioState } |
useTrackContext |
{ playList, curIdx, curPlayId } |
useUIContext |
{ activeUI, interfacePlacement, playListPlacement, playerPlacement, volumeSliderPlacement } |
useResourceContext |
{ elementRefs, customIcons, coverImgsCss } |
You can place a custom component anywhere in the player interface using AudioPlayer.CustomComponent. Use useAudioPlayer inside it to access player state and controls.
import AudioPlayer, {
useAudioPlayer,
InterfacePlacement,
DEFAULT_INTERFACE_GRID_BOUND,
} from "react-modern-audio-player";
const activeUI: ActiveUI = {
all: true,
};
// `as 12` pins the literal type β TS arithmetic widens
// `DEFAULT_INTERFACE_GRID_BOUND + 1` to `number`, which would erase grid-coord
const TOTAL_INTERFACE_GRID_BOUND = (DEFAULT_INTERFACE_GRID_BOUND + 1) as 12;
// `row1-${DEFAULT_INTERFACE_GRID_BOUND}` resolves to `"row1-11"` β
// the first cell beyond the default template area (which fills 1..10).
const customComponentGridArea = `row1-${DEFAULT_INTERFACE_GRID_BOUND}`;
const placement = {
interface: {
customComponentsArea: {
playerCustomComponent: customComponentGridArea,
},
} as InterfacePlacement<typeof TOTAL_INTERFACE_GRID_BOUND>,
/**
* The generic on `InterfacePlacement<N>` is an exclusive upper bound on grid
* indices β usable cells are `1..(N - 1)`. Default is
* `DEFAULT_INTERFACE_GRID_BOUND` (= 11), giving cells `1..10` which the
* built-in template area already occupies (e.g. `playbackRate` at `row1-10`).
* To reserve an additional cell for the custom component, pass `<N + 1>` β
* here `<12>` makes `row1-11` a valid coordinate.
*/
};
const CustomComponent = () => {
const { currentTime, duration, seek, isPlaying, togglePlay } =
useAudioPlayer();
return (
<>
<button onClick={() => seek(currentTime + 30)}>+30s</button>
<button onClick={togglePlay}>{isPlaying ? "Pause" : "Play"}</button>
<span>
{currentTime.toFixed(0)}s / {duration.toFixed(0)}s
</span>
</>
);
};
<AudioPlayer playList={playList} placement={placement} activeUI={activeUI}>
<AudioPlayer.CustomComponent id="playerCustomComponent">
<CustomComponent />
</AudioPlayer.CustomComponent>
</AudioPlayer>;AudioPlayer exposes its built-in controls as static members so you can re-place or augment individual pieces without rebuilding the whole layout.
| Member | Renders |
|---|---|
AudioPlayer.Progress |
progress bar / waveform |
AudioPlayer.Volume |
volume trigger + slider (accepts triggerType?: "click" | "hover", placement?: VolumeSliderPlacement) |
AudioPlayer.PlayList |
sortable playlist drawer (accepts initialExpanded?) |
AudioPlayer.PlayListEmpty |
fallback rendered inside the playlist drawer when playList is empty |
AudioPlayer.PlayButton |
Play + Prev + Next group (Prev/Next visibility follows activeUI.prevNnext) |
AudioPlayer.RepeatButton |
repeat-type button |
AudioPlayer.SpeedSelector |
playback rate dropdown (accepts options?, formatRate?, triggerType?: "click" | "hover", placement?: SpeedSelectorPlacement) |
AudioPlayer.Artwork |
track artwork |
AudioPlayer.TrackInfo |
track title / writer |
AudioPlayer.TrackTime |
current + duration time |
AudioPlayer.CustomComponent |
user-defined slot |
Each slot accepts the full GridItemLayoutProps set β gridArea?, visible?, width?, padding?, justifySelf?, UNSAFE_className? β plus its own domain props. AudioPlayer.TrackTime is the exception: it only exposes visible? because the slot maps to two grid areas internally.
Native HTML attributes (className, style, onClick, data-*, etc.) are not forwarded by compound slots. Compose the underlying primitives (PlayBtn, PrevBtn, NextBtn, etc., still exported) when full DOM control is needed; headless support with native attribute pass-through is planned for v3.
activeUIgoverns the preset (default layout) β which built-in controls are shown.- Compound children are explicit placements that always render (
visibledefaults totrue).
The two layers are orthogonal. Compound children render additively alongside the preset. To truly replace a preset control, disable it in activeUI and render the compound counterpart:
// Remove the default volume, re-place it with a custom gridArea
<AudioPlayer playList={playList} activeUI={{ all: true, volume: false }}>
<AudioPlayer.Volume gridArea="row1-5" />
</AudioPlayer>In development, a console.warn is emitted when a compound slot is rendered while its preset counterpart is still active, so silent duplication is easy to catch.
AudioPlayer.PlayButton is a single slot that renders the Play + Prev + Next group as one unit. There is no separate AudioPlayer.PrevButton / AudioPlayer.NextButton compound slot β Prev / Next are part of the PlayButton compound, and there are two ways to render them.
Place the whole group at a custom grid area; Prev / Next visibility inside the group follows activeUI.prevNnext (which defaults to activeUI.all):
// Re-place the full Play + Prev + Next group at a custom area
<AudioPlayer
playList={playList}
activeUI={{ all: true, playButton: false }} // hide the preset transport
>
<AudioPlayer.PlayButton gridArea="row1-3" />
</AudioPlayer>
// Same compound, but render Play only β Prev / Next hidden by activeUI.prevNnext
<AudioPlayer
playList={playList}
activeUI={{ all: true, playButton: false, prevNnext: false }}
>
<AudioPlayer.PlayButton gridArea="row1-3" />
</AudioPlayer>When you need finer control β placing Prev / Play / Next in different grid cells, applying custom wrappers, or attaching native HTML attributes β drop down to the PlayBtn, PrevBtn, and NextBtn primitives (still exported) and render them inside AudioPlayer.CustomComponent:
import AudioPlayer, {
PrevBtn,
PlayBtn,
NextBtn,
} from "react-modern-audio-player";
<AudioPlayer
playList={playList}
activeUI={{ all: true, playButton: false }} // hide the preset transport
placement={{
interface: { customComponentsArea: { "custom-transport": "row1-3" } },
}}
>
<AudioPlayer.CustomComponent id="custom-transport">
<div className="my-transport">
<PrevBtn isVisible />
<PlayBtn />
<NextBtn isVisible />
</div>
</AudioPlayer.CustomComponent>
</AudioPlayer>;PrevBtn / NextBtn accept an isVisible boolean for symmetry with the compound group's visibility logic; pass false to omit them. Use Method 2 when Method 1's group placement and activeUI.prevNnext toggle aren't enough.
Pass children to AudioPlayer.PlayListEmpty to render a custom node inside the playlist drawer when playList is empty. Omit the slot to keep the default (drawer content renders nothing).
<AudioPlayer playList={[]}>
<AudioPlayer.PlayListEmpty>
<div className="my-empty">Playlist is empty</div>
</AudioPlayer.PlayListEmpty>
</AudioPlayer>PlayListEmpty is a marker slot; its children are consumed by the drawer via context and the component itself does not render in-place.
The player follows WAI-ARIA patterns and is fully navigable by keyboard and screen readers.
All controls are reachable via Tab and respond to standard keyboard activation. The playlist uses the WAI-ARIA "Listbox with Rearrangeable Options" pattern:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move focus between player controls |
Space / Enter |
Activate the focused button (play/pause, prev/next, repeat, mute, playlist) |
ArrowUp / ArrowDown |
Move focus between playlist items |
Alt+ArrowUp / Alt+ArrowDown |
Reorder the focused playlist item |
Enter / Space on a playlist item |
Select and play that track |
Drag-and-drop reordering is preserved as an alternative β keyboard and mouse both call the same onReorder handler.
Common integration mistakes to avoid:
- Don't toggle the theme via
rootContainerProps.style.colorScheme. The native CSScolor-schemeproperty does not switch the player's theme. Use the top-levelcolorSchemeprop, which drives the[data-theme]attribute and re-initializes the waveform colors. - Set the
InterfacePlacementgeneric when placingcustomComponentsAreabeyond row 9. TypeScript rejects values past the default range, so useInterfacePlacement<N>whereNis(max row length + 1)β e.g.InterfacePlacement<11>for"row1-10"(see Custom Component). AudioPlayer.CustomComponentaccepts a single React element child. It usesReact.cloneElementinternally, so passing multiple children or a primitive (string, number) will throw.- Volume is
0..1, not0..100.setVolumeclamps out-of-range values, sosetVolume(50)silently becomessetVolume(1). - Compound slots don't forward native HTML attributes.
<AudioPlayer.Volume className="...">is rejected by TypeScript β onlyGridItemLayoutProps(layout) pass through. Compose the underlying primitives (PlayBtn,PrevBtn,NextBtn, etc., still exported) when you needclassName,style,onClick, ordata-*. Full headless support is planned for v3. id: 0is a valid track id. The reducer uses nullish checks, so tracks withid: 0are handled correctly β don't filter them out ofplayListon the assumption that zero is falsy.- Don't import the CSS manually. Styles are auto-injected via
sideEffects: ["*.css"];import "react-modern-audio-player/dist/index.css"will 404 or double-load. - Multiple mounted
<AudioPlayer>instances don't share React state, but they do share the user's speakers. Each instance has its own provider and its own<audio>element, so the state is isolated β but if two instances both play, the user hears both tracks simultaneously. Coordinate playback yourself (e.g. pause the others when oneplay()fires).
function App() {
return (
<div>
<AudioPlayer
playList={playList}
audioInitialState={{
muted: true,
volume: 0.2,
curPlayId: 1,
}}
placement={{
interface: {
templateArea: {
trackTimeDuration: "row1-5",
progress: "row1-4",
playButton: "row1-6",
repeatType: "row1-7",
volume: "row1-8",
},
},
player: "bottom-left",
}}
activeUI={{
all: true,
progress: "waveform",
}}
/>
</div>
);
}





