How to Build a Custom Audio Player with React and CSS
This article explains how to replace the default HTML5 audio controls with a fully custom UI using CSS, JavaScript, and a React component that handles playback, progress tracking, and user interactions such as play/pause and seeking.
Requirement
Implement audio playback as shown in the picture.
HTML
HTML code:
<audio src="" preload="metadata" controls />Initially I thought that with modern CSS3 it would be easy to skin the audio element, but reality proved otherwise.
Inspecting the audio element's shadow DOM and trying to style it revealed two problems:
The play/pause button is a single element without state; its default CSS uses -webkit-appearance: media-play-button;, which controls both states, leaving no way to style them separately.
The progress bar itself is a shadow DOM, creating two nested shadow DOM layers (the audio element and its internal bar), which also cannot be styled directly.
Therefore I switched to JavaScript control and changed the HTML to:
<div class="audio-wrap">
<audio src="" preload="metadata" controls />
<i class="icon-play"></i> <!-- play/pause button toggled via JS -->
<div class="timeline"> <!-- progress bar -->
<div class="playhead"></div>
</div>
<div class="time-num">
<span class="num-current">00:00</span> / <span class="num-duration">00:00</span>
</div>
</div>Events
loadedmetadata– read the total duration of the audio. timeupdate – update playback progress. canplaythrough – indicates that the media can play continuously without buffering.
Click on icon-play – toggle pause/play.
Click on timeline – seek to a different position.
React Component
The component is written in ES5; the audio URL is passed via props, playback state is managed with component state, and the progress bar is updated using direct DOM manipulation.
var React = require('react');
var ReactDOM = React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
// Simple time formatter, pads numbers < 10 with a leading zero
function formatTime(num) {
var num = parseInt(num);
if (num <= 60) {
if (num < 10) {
num = '0' + num;
}
return num;
}
}
module.exports = React.createClass({
getInitialState: function() {
return { isPlay: false }; // default paused
},
componentDidMount: function() {
var audioNode = ReactDOM.findDOMNode(this.refs.audio),
playNode = ReactDOM.findDOMNode(this.refs.play),
timeline = ReactDOM.findDOMNode(this.refs.timeline),
playhead = ReactDOM.findDOMNode(this.refs.playhead),
timeCurrent = ReactDOM.findDOMNode(this.refs.timeCurrent),
timeDuration = ReactDOM.findDOMNode(this.refs.timeDuration),
timelineWidth = timeline.offsetWidth - playhead.offsetWidth,
that = this,
duration;
function loadedmetadata() {
timeDuration = '00:' + formatTime(audioNode.duration);
timeCurrent = '00:00';
}
this.loadedmetadata = loadedmetadata;
function timeUpdate() {
var playPercent = timelineWidth * (audioNode.currentTime / duration);
playhead.style.webkitTransform = "translateX(" + playPercent + "px)";
playhead.style.transform = "translateX(" + playPercent + "px)";
if (audioNode.currentTime == duration) {
that.setState({ isPlay: false });
}
timeCurrent = '00:' + formatTime(audioNode.currentTime);
}
this.timeUpdate = timeUpdate;
function canplaythrough() {
duration = audioNode.duration;
}
this.canplaythrough = canplaythrough;
function timelineClick(e) {
var newLeft = e.pageX - timeline.offsetLeft;
if (newLeft >= 0 && newLeft <= timelineWidth) {
playhead.style.transform = "translateX(" + newLeft + "px)";
}
if (newLeft < 0) {
playhead.style.transform = "translateX(0)";
}
if (newLeft > timelineWidth) {
playhead.style.transform = "translateX(" + timelineWidth + "px)";
}
audioNode.currentTime = duration * (e.pageX - timeline.offsetLeft) / timelineWidth;
}
this.timelineClick = timelineClick;
audioNode.addEventListener("loadedmetadata", that.loadedmetadata);
audioNode.addEventListener("timeupdate", that.timeUpdate);
audioNode.addEventListener("canplaythrough", that.canplaythrough);
timeline.addEventListener("click", that.timelineClick);
},
componentWillUnmount: function() {
var audioNode = ReactDOM.findDOMNode(this.refs.audio),
timeline = ReactDOM.findDOMNode(this.refs.timeline);
audioNode.removeEventListener("loadedmetadata", this.loadedmetadata);
audioNode.removeEventListener("timeupdate", this.timeUpdate);
audioNode.removeEventListener("canplaythrough", this.canplaythrough);
timeline.removeEventListener("click", this.timelineClick);
},
play: function() {
var audioNode = ReactDOM.findDOMNode(this.refs.audio);
this.setState({ isPlay: !this.state.isPlay });
if (!this.state.isPlay) {
audioNode.play();
} else {
audioNode.pause();
}
},
render: function() {
return (
<div className="audio-wrap">
<audio ref="audio" src={this.props.audioUrl} preload="metadata" controls />
<i ref="play" className={"icon-play" + (this.state.isPlay ? " pause" : "")} onClick={this.play}></i>
<div ref="timeline" className="timeline">
<div ref="playhead" className="playhead"></div>
</div>
<div className="time-num">
<span ref="timeCurrent" className="num-current">00:00</span> / <span ref="timeDuration" className="num-duration">00:00</span>
</div>
</div>
);
},
propTypes: {
audioUrl: React.PropTypes.string.isRequired
}
});Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tencent IMWeb Frontend Team
IMWeb Frontend Community gathering frontend development enthusiasts. Follow us for refined live courses by top experts, cutting‑edge technical posts, and to sharpen your frontend skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
