Skip to Content
raavl

React API

The shared player and renderer, with React ownership and subscriptions.

Import from raavl/react. React and React DOM are optional peer dependencies; vanilla imports do not include them. Imports and SSR rendering are safe without browser globals. Resources are allocated after mount.

Choose an integration

IntegrationUse it for
AudioPlayerA complete player with a default layout and configurable controls
PlayerRoot and individual partsYour own layout with connected controls
usePlayer and state/time hooksYour own markup and behavior
WaveformA standalone waveform, or the waveform part inside a root

Complete audio player

Import the optional stylesheet once. AudioPlayer includes a waveform, seek slider, play/pause button, elapsed time and duration, mute, volume, and status feedback. Playback speed and looping are optional. No application state or control handlers are required:

"use client";
import { AudioPlayer } from "raavl/react";
import "raavl/styles.css";

export function EpisodePlayer() {
  return (
    <AudioPlayer
      source={{ kind: "url", id: "episode", url: "/episode.mp3" }}
      aria-label="Episode player"
      waveform={{ appearance: { style: "bars", height: 120 } }}
      controls={{
        playPause: true,
        time: { mode: "elapsed-duration" },
        seek: false,
        mute: true,
        volume: true,
        playbackRate: { options: [0.75, 1, 1.5, 2] },
        loop: false,
      }}
    />
  );
}

AudioPlayer owns a player when given source, or borrows one when given player. These options are mutually exclusive. onPlayer exposes its controller and receives null during cleanup. onError observes failures; status feedback is built in even when you omit the callback. Its forwarded ref targets the outer div, and ordinary div attributes pass through.

Configuration

PropDefaultBehavior
waveformtruefalse hides the view; an object accepts waveform props except ownership and error callbacks
controlstruefalse hides the transport; an object configures individual controls
statustruefalse hides feedback; an object accepts PlayerStatus props
appearance, themeWaveform defaultsShortcuts for waveform appearance/theme; nested waveform options take precedence
slotsDefault partsReplace or wrap a documented part

The control keys are playPause, seek, time, mute, volume, playbackRate, and loop. Each accepts true, false, or an object of that component's props. Omitted keys retain their defaults; speed and loop default to hidden, and the other controls default to visible. Configuration changes preserve the controller, media, and playback. Hiding the waveform also preserves playback.

waveform={false} hides visualization; set the source's waveform: { kind: 'none' } as well when waveform preparation is unnecessary. Owned roots retain volume, mute, speed, and loop preferences across source changes. Borrowed controllers keep application ownership and settings.

Replace a part

Slots accept a React node, null to hide a part, or a function that receives the default part and returns a wrapper or replacement. Replacements render inside the player root and can use individual controls or usePlayerContext().

<AudioPlayer
  source={source}
  slots={{
    playPause: (
      <PlayPauseButton className="brand-button">
        {(state) => (state.playing ? "Pause episode" : "Listen")}
      </PlayPauseButton>
    ),
    time: (defaultPart) => <strong>{defaultPart}</strong>,
  }}
/>

Slot names are waveform, controls, status, and the seven control keys. A part explicitly disabled in configuration stays hidden even if it has a slot. Replacing controls replaces the complete transport subtree. For arbitrary ordering and layout, compose the individual parts instead.

Individual components

PlayerRoot shares one controller without adding a DOM wrapper. It accepts the same source/borrowed-player options as usePlayer, plus onPlayer and children. Nested roots are independent. A Waveform with no explicit ownership props uses the nearest root, including while that root's controller is still null. An explicit waveform source or player takes precedence over context.

"use client";
import {
  PlayerRoot,
  Waveform,
  PlayPauseButton,
  TimeDisplay,
  SeekSlider,
  MuteButton,
  VolumeSlider,
  PlayerStatus,
} from "raavl/react";
import "raavl/styles.css";

export function CustomPlayer() {
  return (
    <PlayerRoot source={{ kind: "url", id: "episode", url: "/episode.mp3" }}>
      <Waveform appearance={{ style: "bars" }} />
      <div className="my-toolbar">
        <PlayPauseButton />
        <TimeDisplay />
        <MuteButton />
        <VolumeSlider />
      </div>
      <SeekSlider />
      <PlayerStatus />
    </PlayerRoot>
  );
}
ComponentProps and behavior
PlayPauseButtonPlays/pauses; disabled before metadata and during a pending play. Buffering remains pausable. children may receive { playing, pending, buffering, disabled }
SeekSliderNative range in seconds, step defaults to 0.1; formatTime customizes accessible time text
TimeDisplaymode: elapsed, duration, remaining, or default elapsed-duration; optional formatTime(seconds) and children receiving { elapsed, duration, remaining }
MuteButtonIndependent mute toggle; children may receive { pressed, disabled }
VolumeSliderNative range from 0 to 1, step defaults to 0.01; changing volume does not implicitly unmute
PlaybackRateSelectoptions defaults to [0.75, 1, 1.25, 1.5, 2]; values must be between 0.25 and 4; the current speed is included even if absent from options
LoopButtonToggles full-recording looping; children may receive { pressed, disabled }
PlayerStatusDisplays loading/buffering/errors; waveform failures do not disable playback. Children may receive { source, playback, waveform, error, waveformError }

All parts require a root, forward their native element ref, and accept ordinary native attributes, classes, styles, and accessible labels. Internal range/value bounds are managed by the player. Consumer event handlers run first; calling preventDefault() in a click/change handler suppresses the built-in command. Commands catch failures and report them through the root. Cancellation is filtered and the same error object is reported once.

Seeking updates continuously during dragging. Clock updates do not overwrite an active drag value; release, blur, or cancellation returns the slider to the clock. Changing sources cancels an active gesture. Time and seek parts subscribe to the clock independently; time updates do not rerender the whole player/root.

usePlayerContext() returns the nearest root's Player | null for custom parts. Combine it with the existing state/time hooks. Creating a custom part does not transfer ownership of the controller.

Headless player

usePlayer(options?) returns a Player | null without rendering a waveform or requiring the stylesheet. It creates an owned player after commit and manages source changes, cancellation, error reporting, and cleanup. Combine it with the state/time hooks and your own controls:

"use client";

import { useState } from "react";
import { usePlayer, usePlayerState, usePlayerTime } from "raavl/react";

export function HeadlessPlayer() {
  const [error, setError] = useState("");
  const player = usePlayer({
    source: {
      kind: "url",
      id: "episode-1",
      url: "/episode.mp3",
      waveform: { kind: "none" },
    },
    onError: (error) => setError(error.message),
  });
  const ready = usePlayerState(player, (state) => state?.source === "ready");
  const playing = usePlayerState(
    player,
    (state) => state?.playback === "playing",
  );
  const seconds = usePlayerTime(player);

  return (
    <div>
      <button
        disabled={!ready}
        onClick={() => {
          if (!player || !ready) return;
          if (playing) player.pause();
          else void player.play().catch(() => {}); // onError observes playback failures.
        }}
      >
        {playing ? "Pause" : "Play"}
      </button>
      <output aria-label="Playback time">{seconds.toFixed(1)} s</output>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

Use waveform: { kind: 'none' } when no waveform data is needed; omitting it keeps the usual decode behavior. Playback still requires browser media APIs. Imports and server rendering are safe, but playback is not a server-side API. For larger UIs, keep usePlayerTime in the small component that displays time.

OptionsOwnership and behavior
usePlayer({ source, onError? })Owns the player; source changes load through the same controller. Setting or changing the source to null clears it.
usePlayer()Owns an initially idle player; load imperatively with player.setSource(source) and handle its promise.
usePlayer({ player, onError? })Returns an existing player (or null while waiting for one); observes errors without loading, clearing, or disposing it.

An owned player is null before commit and during SSR. Do not dispose it yourself: its lifetime belongs to the component calling the hook. A borrowed player remains application-owned. The source and player options are mutually exclusive. onError observes source, waveform, and playback errors, filters cancellations, and reports the same error object once. Callback changes do not reload audio. Handle promises from imperative commands even when using onError; it does not consume the promises returned to your code.

The hook and <Waveform> share the same player lifecycle. Pass the hook's player to <Waveform player={player} /> once non-null to add an optional visualization.

Waveform

Choose an owned source or a borrowed player:

<Waveform source={{ kind: 'url', id: 'episode', url: '/episode.mp3' }} />
// Or, borrowing a player owned by usePlayer or your application:
<Waveform player={player} />

Never supply source and player together. Setting an owned source to null clears it.

Choosing who owns the player

Both forms render a waveform and support the same interactions. The difference is the lifetime of the audio player behind it. Choose based on how long playback needs to live, rather than whether you need custom controls: both forms support custom controls.

Questionsource={source}player={player}
Who creates the player?Waveform, after mountusePlayer in a parent, or your application
Who loads or changes the audio?Waveform, when source changesThe owning hook's source, or your code with player.setSource(source)
How do controls access it?Capture it with onPlayer={setPlayer}Use the player you already created
What happens when the waveform unmounts?Its view and player are disposed; playback stopsIts view is disposed; the player can keep playing
Who disposes the player?WaveformThe owning hook on unmount, or your application

The examples below use the same controls so you can see that ownership, rather than the buttons, is what changes. Replace /episode.mp3 with an audio asset served by your app. Import raavl/styles.css once in your application. In a Next.js App Router application, keep these components in client modules.

Shared playback controls

Save this as PlaybackControls.tsx alongside the example you want to use. The controls accept a player without taking ownership of it. They stay disabled until source metadata is ready, and handle the play() promise because a browser can reject playback.

"use client";

import type { Player } from "raavl";
import { usePlayerState, usePlayerTime } from "raavl/react";

type Props = {
  player: Player | null;
  onError: (error: Error) => void;
};

function PlaybackTime({ player }: { player: Player | null }) {
  const seconds = usePlayerTime(player);
  return <output aria-label="Playback time">{seconds.toFixed(1)} s</output>;
}

export function PlaybackControls({ player, onError }: Props) {
  const ready = usePlayerState(player, (state) => state?.source === "ready");
  const playing = usePlayerState(
    player,
    (state) => state?.playback === "playing",
  );

  function togglePlayback() {
    if (!player || !ready) return;
    if (playing) player.pause();
    else void player.play().catch(onError);
  }

  return (
    <div>
      <button disabled={!ready} onClick={togglePlayback}>
        {playing ? "Pause" : "Play"}
      </button>
      <PlaybackTime player={player} />
    </div>
  );
}

Keeping the clock subscription in PlaybackTime means time updates rerender that small component instead of the whole card or editor.

Example 1: a podcast episode card with source

Use this when the audio belongs to one visible component, such as an episode card, a voice message, or a sound preview. Removing the card should stop its audio and release its resources.

Episode card

Bundled demo audio · 12 seconds

Play the demo, then remove the card. Its audio stops.

Episode 1: Behind the recording

0.0 s
"use client";

import { useState } from "react";
import type { Player } from "raavl";
import { Waveform } from "raavl/react";
import { PlaybackControls } from "./PlaybackControls";

export function EpisodeCard() {
  const [player, setPlayer] = useState<Player | null>(null);
  const [error, setError] = useState("");

  return (
    <article>
      <h2>Episode 1: Behind the recording</h2>
      <Waveform
        source={{ kind: "url", id: "episode-1", url: "/episode.mp3" }}
        onPlayer={setPlayer}
        onError={(error) => setError(error.message)}
        appearance={{ style: "bars", height: 120 }}
        interaction={{ drag: "seek" }}
        aria-label="Episode 1 waveform"
      />
      <PlaybackControls
        player={player}
        onError={(error) => setError(error.message)}
      />
      {error && <p role="alert">{error}</p>}
    </article>
  );
}

After mount, Waveform creates a player, loads the episode, and passes the player to onPlayer. setPlayer is a stable callback, so the controls can subscribe to that instance. Holding this reference does not transfer ownership: do not dispose it yourself. On cleanup, onPlayer receives null.

Try pressing Play and then removing EpisodeCard from its parent: playback stops. To show another episode, change the source descriptor; Waveform loads it through the existing player. You do not need a createPlayer() call or a cleanup effect in this component.

Example 2: an audio editor with a shared player

Use this when the player needs to outlive a particular waveform view. In an editor, a persistent toolbar can keep playing while the user hides the waveform panel. The parent editor owns playback; the panel borrows it.

Shared player

Bundled demo audio · 12 seconds

Play the demo, then hide the waveform. Audio and the clock keep going.

Episode editor

0.0 s
"use client";

import { useState } from "react";
import { Waveform, usePlayer } from "raavl/react";
import { PlaybackControls } from "./PlaybackControls";

export function AudioEditor() {
  const [showWaveform, setShowWaveform] = useState(true);
  const [error, setError] = useState("");
  // The hook owns playback, even when the waveform panel is hidden.
  const player = usePlayer({
    source: { kind: "url", id: "episode-1", url: "/episode.mp3" },
    onError: (error) => setError(error.message),
  });

  return (
    <section>
      <h2>Episode editor</h2>
      <PlaybackControls
        player={player}
        onError={(error) => setError(error.message)}
      />
      <button
        aria-expanded={showWaveform}
        onClick={() => setShowWaveform((visible) => !visible)}
      >
        {showWaveform ? "Hide waveform" : "Show waveform"}
      </button>
      {showWaveform && player && (
        <Waveform
          player={player}
          appearance={{ style: "bars", height: 180 }}
          interaction={{ drag: "seek" }}
          aria-label="Episode editor waveform"
        />
      )}
      {error && <p role="alert">{error}</p>}
    </section>
  );
}

Try pressing Play, then Hide waveform: the audio and toolbar clock continue. Show waveform reconnects a new view to the same player at its current playback position. Removing AudioEditor unmounts the owning hook, cancels any pending source load, and disposes the player. Error observation stays in the hook, so player errors remain visible even while the waveform panel is hidden.

To select a different recording, update the hook's source. Its controller stays the same, and unchanged inline descriptors do not reload audio. Do not also pass source to the borrowed Waveform. If creating a player manually instead, create and dispose it inside an effect so setup and cleanup remain paired in Strict Mode. Multiple waveforms can borrow this same player, but they share one audio source, playback position, and transport; use separate players for independent audio. For playback across routes, put the owner above the route content so it stays mounted during navigation.

Waveform props

PropPurpose
appearance, viewport, interaction, themeSame settings as the DOM renderer
onPlayer(playerOrNull)Observe controller attachment and cleanup; use a stable callback
onView(viewOrNull)Attach optional timeline/minimap modules to the committed view
refForwarded player reference, null before commit and after cleanup
onError(error)Source, waveform, playback, and annotation errors; cancellation filtered
onDraw()Observe a completed draw
themeVersionChange to refresh Canvas colors after external CSS changes
annotations, annotationsRevision, onAnnotationsCommitControlled annotation document, revision, and proposal callback
defaultAnnotationsInitial library-owned annotation document
annotationStoreBorrow an existing store
annotationRules, snapAnnotationConstraints and pointer snapping
onSelectionChange, onAnnotationSelectSelection callbacks
renderRegionLabel(region)Render a ReactNode label

Ordinary div attributes pass through, including className, style, and aria-label. children are not part of the component API. Choose only one annotation ownership mode. Controlled annotations require both a revision and commit callback.

State hooks

const [player, setPlayer] = useState<Player | null>(null);
const ready = usePlayerState(player, (state) => state?.source === "ready");
const playing = usePlayerState(
  player,
  (state) => state?.playback === "playing",
);
const time = usePlayerTime(player);

usePlayerState(player, selector) observes semantic state. Return primitives or stable objects to avoid unrelated rerenders. usePlayerTime(player) observes the separate clock; keep it near the UI that displays time.

useAnnotations(store) subscribes to an annotation store and returns its snapshot, or null for a null store.

Stable inputs and cleanup

An inline URL descriptor with unchanged fields does not reload audio. Keep large peak buffers, Files, and request objects stable; update revision when asset data changes. Do not mutate supplied peaks.

An owned usePlayer disposes its player on unmount. An owned Waveform disposes its view and player. A borrowed player/store remains with its owner. Keep onView stable and use an effect to mount and dispose optional modules.

On this page