Custom Audio Player in React
June 25, 2025. 23:20 pm 14 min read

Custom Audio Player in React

I’m currently working on a project that allows the user to upload and reproduce long-ish audio files. I wanted to keep a cohesive look and allow the uploaders to bookmark timestamps on their audios and let the listeners jump between these chapters. It was time for me to create a custom audio player!

The engine: HTML’s Audio API

There’s a very robust API for handling audio in the browser called Web Audio API , but since we are not doing some crazy stuff like effects or pipelines, we can base all our work on an HTMLAudioElement. This element can be referenced and controlled via JavaScript. We can also listen for events and react accordingly to the changes.

How does the audio data will look like

First things first. Let’s create a data structure to hold the audio metadata and the chapters and we can create a sample object. We will plug this in to make the metadata section of the component dynamic. This is also the shape of the data an API could return from the Backend.

// types/audio.ts
export interface MyAudio {
  title: string;
  description: string;
  source: string;
  image: string;
  chapters: Array<{
    name: string;
    timestamp: number;
  }>
}

// mockAudio.ts so we have some data to work with
export const mockAudio: MyAudio = {
  title: "Lorem ipsum dolor sit amet",
  description:
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque aliquam augue eu ultricies dignissim. Integer sed aliquam tortor. Donec bibendum convallis eros quis accumsan.",
  source: "/assets/epic-sigmaMusicArt-275522.mp3",
  image: "https://picsum.photos/seed/picsum/300/300",
  chapters: [
    {
      name: "In the bininging",
      timestamp: 0,
    },
    {
      name: "The build up",
      timestamp: 18,
    },
    {
      name: "Breathing time",
      timestamp: 56,
    },
    {
      name: "Fading away",
      timestamp: 95,
    },
    {
      name: "Aftermath",
      timestamp: 100,
    },
  ],
};

Setting up the Component

I’m going to start with the design so we can get that out of the way and later on we can plug in the behavior.

I want a big fat play button with two smaller buttons that allow the user to go back or forward 10 seconds. I also want a time slider, some text to show the duration and the current time and some metadata.

I’m going to use TailwindCSS and Iconify to make my life easier. I’ll also leave the time slider styling up to you.

import { Icon } from "@iconify/react"

export function AudioPlayer() {
	return (
		<div className="bg-slate-100 p-4 rounded-lg shadow-md">
			<figure className="w-[300px] pb-2 mb-4 border-b-2 border-slate-200">
				<img
				src="https://picsum.photos/seed/picsum/300/300"
				className="rounded-sm mb-2"
				/>
				<figcaption>
				  <h2 className="text-lg">{audio.title}</h2>
          <div className="text-sm text-slate-600">
            {audio.description}
          </div>
				</figcaption>
			</figure>
			<div className="flex flex-row gap-4 items-center justify-center">
				<button className="bg-slate-200 text-slate-950 hover:bg-slate-300 hover:text-rose-500 rounded-full p-2 shadow-md hover:shadow-none cursor-pointer">
					<Icon icon="material-symbols:replay-10" className="size-6" />
				</button>
				<button className="bg-slate-200 text-slate-950 hover:bg-slate-300 hover:text-rose-500 rounded-full p-4 shadow-md hover:shadow-none cursor-pointer">
					<Icon icon="material-symbols:play-arrow" className="size-10" />
				</button>
				<button className="bg-slate-200 text-slate-950 hover:bg-slate-300 hover:text-rose-500 rounded-full p-2 shadow-md hover:shadow-none cursor-pointer">
					<Icon icon="material-symbols:forward-10" className="size-6" />
				</button>
			</div>
			<div className="mt-4">
				<input type="range" className="w-full" />
			</div>
			<div className="text-sm flex flex-row justify-between mb-4">
        {/* This is a "magic" function that formats seconds into hh:mm:ss */}
				<div>{formatTime(position)}</div>
        <div>{formatTime(duration)}</div>
			</div>
			<div>
				<div className="font-bold mb-2">Chapters</div>
				<ul className="flex flex-col gap-2 [&>li>button]:flex [&>li>button]:gap-4 [&>li>button]:hover:text-rose-500 [&>li>button]:cursor-pointer">
					{audio.chapters.map((chapter, index) => (
            <li key={chapter.timestamp}>
              <button>
                <span className="font-mono">{formatTime(chapter.timestamp)}</span>
                <span>{chapter.name}</span>
              </button>
            </li>
          ))}
				</ul>
			</div>
		</div>
	);
}

And this is how the UI looks like:

React Custom Audio Player

Let’s make it functional

I was thinking we can use a hook to abstract this logic in case we need to create variants like a full screen version or a thin sticky version.

I envision this hook as a black box that receives a source audio and returns utilities to play it and get feedback about the reproduction.

Let’s create a new Audio element in a ref and hook some listeners to some state:

import { useEffect, useRef, useState } from "react";

interface UseAudioPlayerProps {
  src: string;
}

export function useAudioPlayer({ src }: UseAudioPlayerProps) {
  // We hold our audio element here.
  const audio = useRef(new Audio());
  // Some state
  const [isReady, setIsReady] = useState(false);
  const [isPlaying, setIsPlaying] = useState(false);
  const [duration, setDuration] = useState(0);
  const [position, setPosition] = useState(0);

  // This runs when the file is ready
  const hndCanPlay = () => {
    setIsReady(true);
    setDuration(audio.current.duration);
  };

  // Playback controls
  const play = () => {
    audio.current.play();
  };
  const pause = () => {
    audio.current.pause();
  };

  const skipBack = () => {
    // Let's keep things over 0
    if (position - 10 <= 0) {
      audio.current.currentTime = 0;
      return;
    }
    audio.current.currentTime -= 10;
  };
  const skipForward = () => {
    // And guard the user from trying to go beyond the duration
    if (position + 10 >= duration) {
      audio.current.currentTime = duration;
      return;
    }
    audio.current.currentTime += 10;
  };

  // Reflect changes on the position and state
  const hndTimeUpdate = () => {
    setPosition(audio.current.currentTime);
  };
  const hndPlaying = () => {
    setIsPlaying(true);
  };
  const hndPause = () => {
    setIsPlaying(false);
  };

  // Handle src loading
  // TODO: Handle dynamic changes to the src prop? Also, error handling?
  useEffect(() => {
    audio.current.src = src;
  }, [src]);

  useEffect(() => {
    // This will only load the metadata on initialization. Let's be mindful with the user's internet usage
    audio.current.preload = "metadata";
    // Hooking some listeners
    audio.current.addEventListener("canplay", hndCanPlay);
    audio.current.addEventListener("timeupdate", hndTimeUpdate);
    audio.current.addEventListener("playing", hndPlaying);
    audio.current.addEventListener("pause", hndPause);
    return () => {
      // Clean up the listeners like good boys
      audio.current.removeEventListener("canplay", hndCanPlay);
      audio.current.removeEventListener("timeupdate", hndTimeUpdate);
      audio.current.removeEventListener("playing", hndPlaying);
      audio.current.removeEventListener("pause", hndPause);
    };
  }, []);

  // The hook's API
  return {
    isReady,
    isPlaying,
    duration,
    position,
    play,
    pause,
    skipBack,
    skipForward,
  };
}

Now let’s hook the functions and state to our component!

export function AudioPlayer() {
  const {
    isReady,
    isPlaying,
    play,
    pause,
    skipBack,
    skipForward,
    duration,
    position,
  } = useAudioPlayer({ src: "/assets/epic-sigmaMusicArt-275522.mp3" });

  // Toggle between play and pause
  const hndPlayPauseToggle = () => {
    if (isPlaying) {
      pause();
    } else {
      play();
    }
  };

  // If the audio is not playable, show a loader
  if (!isReady) {
    return (
      <div className="rounded-lg bg-slate-100 p-4 shadow-md">
        <div>Loading...</div>
      </div>
    );
  }

  return (
    <div className="rounded-lg bg-slate-100 p-4 shadow-md">
      <figure className="mb-4 w-[300px] border-b-2 border-slate-200 pb-2">
        <img
          src="https://picsum.photos/seed/picsum/300/300"
          className="mb-2 rounded-sm"
        />
        <figcaption>
          <h2 className="text-lg">{audio.title}</h2>
          <div className="text-sm text-slate-600">{audio.description}</div>
        </figcaption>
      </figure>
      <div className="flex flex-row items-center justify-center gap-4">
        <button
          className="cursor-pointer rounded-full bg-slate-200 p-2 text-slate-950 shadow-md hover:bg-slate-300 hover:text-rose-500 hover:shadow-none"
          /* On click, skip back 10 secs */
          onClick={skipBack}
        >
          <Icon icon="material-symbols:replay-10" className="size-6" />
        </button>
        <button
          className="cursor-pointer rounded-full bg-slate-200 p-4 text-slate-950 shadow-md hover:bg-slate-300 hover:text-rose-500 hover:shadow-none"
          /* Toggle play and pause on click */
          onClick={hndPlayPauseToggle}
        >
          {/* Display the correct icon depending on isPlaying */}
          {isPlaying ? (
            <Icon icon="material-symbols:pause" className="size-10" />
          ) : (
            <Icon icon="material-symbols:play-arrow" className="size-10" />
          )}
        </button>
        <button
          className="cursor-pointer rounded-full bg-slate-200 p-2 text-slate-950 shadow-md hover:bg-slate-300 hover:text-rose-500 hover:shadow-none"
          /* On click, skip forward 10 secs */
          onClick={skipForward}
        >
          <Icon icon="material-symbols:forward-10" className="size-6" />
        </button>
      </div>
      <div className="mt-4">
        <input
          type="range"
          className="w-full"
          /* Set the value to the current possition and use the duraton as max */
          value={position}
          max={duration}
        />
      </div>
      <div className="mb-4 flex flex-row justify-between text-sm">
        {/* This is a magic function that formats seconds into hh:mm:ss */}
        <div>{formatTime(position)}</div>
        <div>{formatTime(duration)}</div>
      </div>
      <div>
        <div className="mb-2 font-bold">Chapters</div>
        <ul className="flex flex-col gap-2 [&>li>button]:flex [&>li>button]:cursor-pointer [&>li>button]:gap-4 [&>li>button]:hover:text-rose-500">
          {audio.chapters.map((chapter, index) => (
            <li key={chapter.timestamp}>
              <button>
                <span className="font-mono">{formatTime(chapter.timestamp)}</span>
                <span>{chapter.name}</span>
              </button>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Works like a charm!

The slider

Now we need to implement some time traveling. We will need some neat tricks to accomplish this.

The first thing we need to do is to create a function that directly sets the time to an arbitrary value (with some guards, of course):

// useAudioPlayer.ts

// This function allows you to set a value between 0 and the duration of the audio
const skipTo = (position: number) => {
  if (position < 0) audio.current.currentTime = 0;
  if (position > duration) audio.current.currentTime = duration;
  audio.current.currentTime = position;
};

// And return the function in the API
return {
  // ...
  skipTo,
  // ...
};

The problem with this component is that it has to be controlled by the current time of the Audio element, but at the same time allow the user to control the value.

Let’s abstract the component to isolate the complexity.

This is what we need to do:

  • When the user is not interacting, the value of the slider is given by the value prop
  • When the user starts interacting (eg: when the mouse down event is triggered), the value of the slider should be given by an internal value
  • When the user stops interacting (eg: the mouse up event), the value of the slider should go back to the value, but a different event handler should trigger so we can react to it. I called this event onCommitChange.
  • When onCommitChange is called, we can hook our skip behavior and point to the new position. If we do this, the value should be now the position the user wants.
// Slider.tsx
import {
  useState,
  type ChangeEventHandler,
  type MouseEventHandler,
} from "react";

// We extend the HTML Input props removing the type (since we shouldn't be a ble to change the slider type) and add a new callback.
type SliderProps = Omit<React.HTMLProps<HTMLInputElement>, "type"> & {
  // The onCommitChange callback will be called once the final value is decided by the user
  onCommitChange?: (newValue: number) => void;
};

export function Slider({
  value,
  onCommitChange,
  onChange,
  onMouseDown,
  onMouseUp,
  ...rest
}: SliderProps) {
  // We need to know if the user is changing the value or not
  const [isDragging, setIsDragging] = useState(false);
  // And keep track of the current user value
  const [userTime, setUserTime] = useState(0);

  // We transition to user mode when the user holds the mouse down
  // TODO: Make this work with touch events?
  const hndMouseDown: MouseEventHandler<HTMLInputElement> = (e) => {
    setIsDragging(true);
    // And we call the original callback in case the user needs it 🤷
    onMouseDown?.(e);
  };

  // This handler tracks the user value as it changes
  const hndChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    const newValue = e.currentTarget.value;
    setUserTime(parseFloat(newValue));
    onChange?.(e);
  };

  const hndMouseUp: MouseEventHandler<HTMLInputElement> = (e) => {
    // When the user drops the thumb, we go back to auto mode
    setIsDragging(false);
    // We call the original callback
    onMouseUp?.(e);
    // And we call the onCommitChange here with the user value
    onCommitChange?.(userTime);
  };

  // And we return the same input and pass down any prop the user wants to use on the input element
  return (
    <input
      type="range"
      onMouseDown={hndMouseDown}
      onMouseUp={hndMouseUp}
      onChange={hndChange}
      value={isDragging ? userTime : value}
      {...rest}
    />
  );
}

We swap the slider with our new component and hook the event:

export function AudioPlayer() {
  const {
    // ...
    skipTo,
    duration,
    position,
  } = useAudioPlayer({ src: '/assets/epic-sigmaMusicArt-275522.mp3' })

  // ...
  const hndSliderChange = (newPosition: number) => {
    skipTo(newPosition)
  }

  // ...
  render (
    {/* ... */}
    <Slider
      className="w-full"
      value={position}
      max={duration}
      onCommitChange={hndSliderChange}
    />
    {/* ... */}
  )
}

There we go. You can now change the current time with the slider without messing up the playback.

The chapters

With this new API function we can now implement the chapters feature. It’s as easy as skipping to that time on click. But wait! There’s more! How can you know if we are playing a specific chapter?!

Let’s create a handler for the bookmark buttons:

// Currying the timestamp so we can use it with our skipTo function
const hndChapterClick = (timestamp: number) => () => {
  skipTo(timestamp);
}

// ... in the chapter list
{audio.chapters.map((chapter) => (
  <li key={chapter.timestamp}>
    <button onClick={hndChapterClick(chapter.timestamp)}>
      <span className="font-mono">{formatTime(chapter.timestamp)}</span>
      <span>{chapter.name}</span>
    </button>
  </li>
))}

Now the user can browse around using these predefined timestamps. But how can we check if the user is listening to a specific chapter?

The way chapters are set up is that they indicate where a section begins (timestamp), but we don’t know when they end. We can assume the chapter ends when the next one starts and the last one should last until the end of the audio. If the user lands on a timestamp between 0 and the first chapter, then that’s an unnamed bookmark and we assume it’s not listening to any bookmark.

This is how the bookmarks would look like:

start 0s                                             end 10s
|==========|================|================|===========|
          A (1s)           B (5s)           C (8s)

nothing: from 0s to 1s
A: from 1s to 5s
B: from 5s to 8s
C: from 8s to end (10s)

Let’s build a function that creates an index with the start and end times for each bookmark. Let’s assume they are always in order to keep things nice and easy.

function buildBoundaryIndex(
  chapters: Array<{ name: string; timestamp: number }>,
  duration: number
) {
  // Initialize the index
  const boundaries: [number, number, number][] = [];
  let currentBoundaryStart = -1;
  let currentBoundaryIndex = -1;
  // Let's go through all the chapters
  for (let index = 0; index < chapters.length; index++) {
    const chapter = chapters[index];
    if (index === 0) {
      // This is the first item. Set variables and continue.
      currentBoundaryStart = chapter.timestamp;
      currentBoundaryIndex = index;
      continue;
    }
    // This is the index entry that we will create with the previous start time and the current start time setting it to the previous index.
    const newBoundary = [
      currentBoundaryStart,
      chapter.timestamp,
      currentBoundaryIndex,
    ] as [number, number, number];
    // We add the entry to the index
    boundaries.push(newBoundary);
    // And set the data for the next chapter
    currentBoundaryStart = chapter.timestamp;
    currentBoundaryIndex = index;
  }

  // This is the last index entry that spans from the last item we processed to the end of the audio
  const newBoundary = [
    currentBoundaryStart,
    duration,
    (currentBoundaryIndex = chapters.length - 1),
  ] as [number, number, number];
  // We add the entry
  boundaries.push(newBoundary);

  // And return the index
  return boundaries;
}

Now we need a function that given a timestamp between 0 and the duration, it returns the current chapter (or null if we are on an unnamed chapter).

function findChapter(
  boundaryIndex: [number, number, number][],
  currentTime: number
) {
  // We find the first entry that matches the boundary: between start and end times
  const chapter = boundaryIndex.find(
    ([start, end]) => currentTime >= start && currentTime < end
  );
  // If there is an entry
  if (chapter) {
    // We return the index we stored
    const index = chapter[2];
    return index;
  }
  // Otherwise, we return null
  return null;
}

Now, we will wrap this logic inside another hook since we want to keep the single responsibility principle here.

export function useChapters(
  currentTime: number,
  chapters: Array<{ name: string; timestamp: number }>,
  totalDuration: number
) {
  // First, we create the index in a useMemo hook to recalculate if we ever change the chapters or the duration
  const boundaries = useMemo(() => {
    return buildBoundaryIndex(chapters, totalDuration);
  }, [chapters, totalDuration]);

  // Next, we set a state to hold the current active chapter
  const [activeChapter, setActiveChapter] = useState<number | null>(null);

  // Here we also use useMemo to calculate the start and end times for the current chapter
  const { boundaryStart, boundaryEnd } = useMemo(() => {
    const activeBoundaries = boundaries.find(
      ([, , index]) => index === activeChapter
    );
    if (!activeBoundaries) return { boundaryStart: -1, boundaryEnd: -1 };
    const [boundaryStart, boundaryEnd] = activeBoundaries;
    return {
      boundaryStart,
      boundaryEnd,
    };
  }, [boundaries, activeChapter]);

  // Lastly, we use useEffect to watch for changes on the currentTime and the chapter index. If any of them change, we have to recalculate which chapter we are in
  useEffect(() => {
    // If the current time is still between the current chapter start and end times, return early
    if (currentTime >= boundaryStart && currentTime <= boundaryEnd) return;
    // Otherwise, find the new chapter
    setActiveChapter(findChapter(boundaries, currentTime));
  }, [currentTime, boundaries]);

  // Return the active chapter
  return activeChapter;
}

Once implemented, this hook will return the index of the chapter or null depending on the chapters and the current time.

export function AudioPlayer({ audio }: AudioPlayerProps) {
  const {
    // ...
    duration,
    position,
  } = useAudioPlayer({ src: audio.source })

  const activeChapter = useChapters(position, audio.chapters, duration)

  return (
    {/* ... */}
    {audio.chapters.map((chapter, index) => (
      <li key={chapter.timestamp}>
        <button
          onClick={hndChapterClick(chapter.timestamp)}
          // match the index against the activeChapter and style accordingly
          className={activeChapter === index ? 'text-rose-500' : undefined}
        >
          <span className="font-mono">{formatTime(chapter.timestamp)}</span>
          <span>{chapter.name}</span>
        </button>
      </li>
    ))}
    {/* ... */}
  )
}

Bada bim bada boom!

React Custom Audio Player with a highlighted chapter

The way this is hooked makes it possible for the user to use the slider to change the time and the chapter will update accordingly.

Conclusion

This is a very useful component for, for example, podcasts where you want to highlight moments or sections.

This post shows only the happy path, but it’s a good start if you want to build something like this.

You can clone this repo from here and go nuts.