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.

Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
How to Build a Custom Audio Player with React and CSS

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
    }
});
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

audiocustom-player
Tencent IMWeb Frontend Team
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.