Circular Draggable Audio Player in React
Back when I was hustling in the agency world, I had this fun project as part of a campaign - building a circular draggable audio player! There was no ready-made solution back then and jQuery was still the go-to. So, I had to roll up my sleeves and create one from scratch.
I was quite proud about what I built that I threw it on CodePen and pretty much forgot about it. But guess what? It turned out to be quite a hit! A bunch of people reached out to me, asking about it.
Here's the initial version on CodePen using HTML, CSS, and jQuery:
But then I thought, why not create a React version for all the modern devs out there? So, here it is:
Breaking it down
Let's break down the code step-by-step so you can understand how it all works.
import React, { useRef, useEffect, useState, useCallback } from 'react'; const AudioPlayer = ({ src }) => { const audioRef = useRef(null); const circleRef = useRef(null); const dotRef = useRef(null); const pathRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const isDraggingRef = useRef(false); const wasPlayingBeforeDragRef = useRef(false); const [totalLength, setTotalLength] = useState(0); const updateVisuals = useCallback( percentage => { if (!pathRef.current || !dotRef.current) return; const length = totalLength || pathRef.current.getTotalLength(); const offset = length - (percentage / 100) * length; pathRef.current.style.strokeDashoffset = offset; const point = pathRef.current.getPointAtLength((percentage / 100) * length); dotRef.current.setAttribute('cx', point.x); dotRef.current.setAttribute('cy', point.y); }, [totalLength], ); const handleTimeUpdate = useCallback(() => { if (isDraggingRef.current) return; if (!audioRef.current || !isFinite(audioRef.current.duration)) return; const { currentTime, duration } = audioRef.current; const percentage = (currentTime / duration) * 100; updateVisuals(percentage); }, [updateVisuals]); const getPercentageFromEvent = useCallback(e => { if (!circleRef.current) return null; const bounds = circleRef.current.getBoundingClientRect(); const radius = bounds.width / 2; const dx = e.clientX - (bounds.left + radius); const dy = e.clientY - (bounds.top + radius); let angle = Math.atan2(dy, dx); if (angle < 0) angle += 2 * Math.PI; angle = (angle + Math.PI / 2) % (2 * Math.PI); return (angle / (2 * Math.PI)) * 100; }, []); const handleDrag = useCallback( e => { const percentage = getPercentageFromEvent(e); if (percentage === null) return; updateVisuals(percentage); if (audioRef.current && isFinite(audioRef.current.duration)) { audioRef.current.currentTime = (percentage * audioRef.current.duration) / 100; } }, [getPercentageFromEvent, updateVisuals], ); useEffect(() => { const path = pathRef.current; const length = path.getTotalLength(); setTotalLength(length); path.style.strokeDasharray = length; path.style.strokeDashoffset = length; }, []); const handleMouseDown = e => { isDraggingRef.current = true; wasPlayingBeforeDragRef.current = !!audioRef.current && !audioRef.current.paused; if (wasPlayingBeforeDragRef.current) { audioRef.current.pause(); } handleDrag(e); }; const handleMouseUp = useCallback(() => { if (!isDraggingRef.current) return; isDraggingRef.current = false; if (wasPlayingBeforeDragRef.current && audioRef.current) { audioRef.current.play().catch(() => {}); } }, []); useEffect(() => { const handleMouseMove = e => { if (isDraggingRef.current) handleDrag(e); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [handleDrag, handleMouseUp]); const playPauseHandler = async () => { const audio = audioRef.current; if (!audio) return; if (audio.paused) { document.querySelectorAll('audio').forEach(a => { if (a !== audio) a.pause(); }); try { await audio.play(); } catch { // Play request was interrupted — safe to ignore } } else { audio.pause(); } }; useEffect(() => { const audio = audioRef.current; if (!audio) return; const handleAudioEnd = () => { setIsPlaying(false); if (pathRef.current) { pathRef.current.style.strokeDashoffset = totalLength; } if (dotRef.current && pathRef.current) { const point = pathRef.current.getPointAtLength(0); dotRef.current.setAttribute('cx', point.x); dotRef.current.setAttribute('cy', point.y); } }; const onPlay = () => setIsPlaying(true); const onPause = () => { if (!isDraggingRef.current) setIsPlaying(false); }; audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('ended', handleAudioEnd); audio.addEventListener('play', onPlay); audio.addEventListener('pause', onPause); return () => { audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('ended', handleAudioEnd); audio.removeEventListener('play', onPlay); audio.removeEventListener('pause', onPause); }; }, [totalLength, handleTimeUpdate]); return ( <section className={`slide__audio ${isPlaying ? 'playing' : 'paused'}`}> <audio ref={audioRef} className="slide__audio-player" controls preload="metadata"> <track kind="captions" /> <source src={src} type="audio/mpeg" /> </audio> <figure className="audio__controls"> <svg version="1.1" id="circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" ref={circleRef} > <path id="background" fill="none" stroke="#6d6d6d" strokeLinecap="round" d="M50,2.9L50,2.9C76,2.9,97.1,24,97.1,50v0C97.1,76,76,97.1,50,97.1h0C24,97.1,2.9,76,2.9,50v0C2.9,24,24,2.9,50,2.9z" onClick={e => handleDrag(e)} /> <path id="seekbar" ref={pathRef} fill="none" strokeLinecap="round" d="M50,2.9L50,2.9C76,2.9,97.1,24,97.1,50v0C97.1,76,76,97.1,50,97.1h0C24,97.1,2.9,76,2.9,50v0C2.9,24,24,2.9,50,2.9z" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} /> <circle className="progress-handle" ref={dotRef} r="4" cx="50" cy="2.9" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} /> </svg> <svg xmlns="http://www.w3.org/2000/svg" className="equalizer" viewBox="0 0 100 100"> <g className="equalizer-group"> <rect className="bar" /> <rect className="bar" /> <rect className="bar" /> <rect className="bar" /> <rect className="bar" /> </g> </svg> <button className="play-pause" onClick={playPauseHandler} aria-label="Play Pause Button"> <span className="hidden">{isPlaying ? 'Pause' : 'Play'}</span> </button> </figure> </section> ); }; export default AudioPlayer;
Importing Dependencies
First off, we're importing React and some hooks like useRef, useEffect, useState, and useCallback to handle state and side effects in our component.
Setting up Refs and State
We're using useRef to grab references to our audio element and SVG elements. The isDraggingRef and wasPlayingBeforeDragRef are refs rather than state — this avoids unnecessary re-renders and stale closures in our event handlers. The isPlaying state drives the UI, while totalLength tracks the SVG path length.
Updating Visuals
The updateVisuals function handles moving the seekbar and dot to the correct position given a percentage. It's separated from audio logic so we can call it from both the time update and drag handlers without duplication.
Handling Seek Circle Dragging
The getPercentageFromEvent function converts a mouse position into a percentage around the circle. handleDrag uses this to update both the visuals and the audio's current time. Crucially, on mouse down we pause the audio first (remembering whether it was playing), and on mouse up we resume playback. This prevents the browser's "play() interrupted by pause()" error that occurs when you seek while audio is playing.
Initializing SVG Path Length
In useEffect, we set up the initial total length of the SVG path.
Managing Mouse Events
We add and remove document-level event listeners for mouse movement and mouse up to handle dragging. Since isDraggingRef is a ref, these handlers always read the latest value without needing to be recreated.
Controlling Play and Pause
playPauseHandler is async and wraps audio.play() in a try/catch. The play/pause UI state is driven entirely by the native play and pause audio events rather than being set manually, which keeps everything in sync. The onPause listener also skips updating state during a drag so the UI stays in the "playing" state while scrubbing.
Cleanup
We ensure to remove event listeners and clean up side effects in our useEffect hooks to prevent memory leaks.
Rendering JSX
Finally, we return the JSX that renders our audio player, complete with SVG-based seek bar and play/pause button.
A note on audio sources and CSP
If your site uses a Content Security Policy, you'll need to make sure your media-src directive allows the domain you're loading audio from. Without it, the browser will silently block the audio file and nothing will play.
The easiest way to avoid this altogether is to host the audio file yourself — drop an MP3 into your public folder and reference it with a relative path like /audio-sample.mp3. That way it's served from your own domain and covered by media-src 'self'.
If you do need to load audio from an external source, add the domain to your CSP:
media-src 'self' https://example.com
Hope this helps! If you have any questions or want to chat, hit me up on Bluesky.
Happy coding! 🎧🎶