How to implement buffering on a custom video player in React.js

An extensive guide to building a video player that has playback speed, seeking, pause/play, and buffer progress indicator using React.js and Chakra UI.

Awa Dieudonne
11 min readJan 21, 2023

In this article, I’m going to walk you through creating a video player, implementing buffering, playback speed, seeking (or timeline scrubbing), and pause/play. We will go from creating a simple video player with just pause/play functionality. Later on, we will add a timeline with play and buffer progress bars indicated.

Check out my blog website for the original post: here

Create a simple video player

Start by setting up a React.js and Chakra UI application, if you don’t know how that is done, please follow the instructions in this guide: Getting started with Chakra UI and React.js.

If you already have your React.js setup, create a file called `Player.tsx` in the root folder. Inside the file, render a simple video tag that expects src, muted, and autoplay props. The video component should have a basic Video styled component with styles that ensure it covers the full height of every parent element it is within.

const Video = styled.video`
flex-shrink: 1;
height: 100%;
object-fit: cover;
border-radius: 10px;
`;

In the `Player.tsx` component, return the video element with the three props: `src`, `muted`, and `autoplay`.

interface Props {
src: string;
muted?: boolean;
autoPlay?: boolean;
}

const Player = (props: Props) => {
const { src, autoPlay, muted } = props;

const videoRef = useRef<HTMLVideoElement>(null);

const handlePlayPauseClick = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}
};

return (
<Flex
cursor="pointer"
align="center"
justify="center"
rounded="10px"
overflow="hidden"
>
<Video
autoPlay={autoPlay}
muted={muted}
src={src}
onClick={handlePlayPauseClick}
ref={videoRef}
/>
</Flex>
);
};

export default Player;

This gives us a basic video player with a click to play or pause the video. It does not include a timeline or any other player controls, just play/pause. Import the component into `App.tsx` and render it there.

// App.tsx
import "./styles.css";
import Player from "./Player";
import { Flex, Heading } from "@chakra-ui/react";

export default function App() {
return (
<Flex flexDir="column" px="1rem" className="App">
<Heading>Video player</Heading>
<Player src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" />
</Flex>
);
}

Add timeline, buffering, and play progress to the video player

The timeline is where the playback and buffer progress bars are indicated in blue and white, respectively. We will use React’s `useRef` to create a reference to the Video element, allowing us to manipulate it with DOM methods. We will also create refs for the buffer and playback progress components. These components will be updated as the user interacts with the player or as the playback or buffer changes.

Inside the Player component, add a `useEffect` hook. This hook will listen for several events on the video element’s ref and update the UI accordingly. The events are: `waiting`, `play`, `playing`, `pause`, `progress`, and `timeupdate`.

Quick Summary

  • `waiting`: Fired when playback stops because of a temporary lack of data. When this event gets fired, we will set our waiting flag to `true`, and set it back to false in other events.
  • `play`: Fired when the paused property is changed from true to false, as a result of the play method, or the autoplay attribute.
  • `pause`: Fired when playback is paused.
  • `playing`: Fired when the audio/video is playing after pausing or stopping for buffering.
  • `progress`: Fired when loading external resources for the video.
  • `timeupdate`: Fired when the time indicated by the `currentTime` attribute has been updated
// Player.tsx
import { Button, Flex, Spinner } from "@chakra-ui/react";
import styled from "@emotion/styled";
import React, { useEffect, useRef, useState } from "react";

const Video = styled.video`
flex-shrink: 1;
height: 100%;
object-fit: cover;
border-radius: 10px;
`;

interface Props {
src: string;
muted?: boolean;
autoPlay?: boolean;
}

const Player = (props: Props) => {
const { src, autoPlay, muted } = props;
const [isWaiting, setIsWaiting] = useState(false);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [durationSec, setDurationSec] = useState(1);
const [elapsedSec, setElapsedSec] = useState(1);

const videoRef = useRef<HTMLVideoElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const bufferRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!videoRef.current) {
return;
}

const onWaiting = () => {
if (isPlaying) setIsPlaying(false);
setIsWaiting(true);
};

const onPlay = () => {
if (isWaiting) setIsWaiting(false);
setIsPlaying(true);
};

const onPause = () => {
setIsPlaying(false);
setIsWaiting(false);
};

const element = videoRef.current;

const onProgress = () => {
if (!element.buffered) return;
const bufferedEnd = element.buffered.end(element.buffered.length - 1);
const duration = element.duration;
if (bufferRef && duration > 0) {
bufferRef.current!.style.width = (bufferedEnd / duration) * 100 + "%";
}
};

const onTimeUpdate = () => {
setIsWaiting(false);
const duration = element.duration;
setDurationSec(duration);
setElapsedSec(element.currentTime);
if (progressRef && duration > 0) {
progressRef.current!.style.width =
(element.currentTime / duration) * 100 + "%";
}
};

element.addEventListener("progress", onProgress);
element.addEventListener("timeupdate", onTimeUpdate);

element.addEventListener("waiting", onWaiting);
element.addEventListener("play", onPlay);
element.addEventListener("playing", onPlay);
element.addEventListener("pause", onPause);

// clean up
return () => {
element.removeEventListener("waiting", onWaiting);
element.removeEventListener("play", onPlay);
element.removeEventListener("playing", onPlay);
element.removeEventListener("pause", onPause);
element.removeEventListener("progress", onProgress);
element.removeEventListener("timeupdate", onTimeUpdate);
};
}, [videoRef.current]);

const handlePlayPauseClick = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}
};

return (
<Flex
flexDir="column"
cursor="pointer"
align="center"
justify="center"
pos="relative"
rounded="10px"
overflow="hidden"
_hover={{
".timeline-container": {
opacity: 1
}
}}
>
{isWaiting && <Spinner pos="absolute" color="white" />}
<Video
autoPlay={autoPlay}
muted={muted}
src={src}
onClick={handlePlayPauseClick}
ref={videoRef}
/>
<Flex
w="full"
h="100px"
bg="linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5))"
pos="absolute"
opacity={0}
transition="opacity 0.5s linear"
className="timeline-container"
left={0}
bottom={0}
align="flex-end"
px="1rem"
>
<Flex flexDir="column" w="full" align="center">
<Flex
w="full"
transition="height 0.1s linear"
className="timeline"
h="4px"
mb="0.5rem"
rounded="10px"
bg="rgba(193, 193, 193, 0.5)"
_hover={{ height: "5px" }}
overflow="hidden"
>
<Flex pos="relative" w="full" h="full">
<Flex
h="full"
className="play-progress"
bg="#0CAADC"
zIndex={1}
ref={progressRef}
/>
<Flex
pos="absolute"
h="full"
className="buffer-progress"
bg="#FDFFFC"
ref={bufferRef}
/>
</Flex>
</Flex>
</Flex>
</Flex>
</Flex>
);
};

export default Player;

Video buffering

The following code snippet gets the buffer progress and updates the timeline.

...
const onProgress = () => {
if (!element.buffered) return;
const bufferedEnd = element.buffered.end(element.buffered.length - 1);
const duration = element.duration;
if (bufferRef && duration > 0) {
bufferRef.current!.style.width = (bufferedEnd / duration) * 100 + "%";
}
};

element.addEventListener("progress", onProgress);
...
  • `element.buffered` represents a collection of buffered time ranges.
  • `element.buffered.end(element.buffered.length — 1)` gets the time at the end of the buffer range.

With this value, we can now compute the current buffer range and update the buffer progress accordingly.

...
const onTimeUpdate = () => {
setIsWaiting(false);
const duration = element.duration;
setDurationSec(duration);
setElapsedSec(element.currentTime);
if (progressRef && duration > 0) {
progressRef.current!.style.width =
(element.currentTime / duration) * 100 + "%";
}
};

element.addEventListener("timeupdate", onTimeUpdate);
...

Make sure to call `setIsWaiting(false)` to set the waiting flag to false, so the loader does not continue to run when the player is not buffering.

Track the `elapsedTimeSec` and `duration`, as they will be needed to add a time tracker to the player.

At this point, the playback and buffer progress bars should be displayed on the player, as shown below:

https://firebasestorage.googleapis.com/v0/b/oauth-303809.appspot.com/o/article%2F2c86cece-244c-40e7-9adc-faa74436440d-p.png?alt=media&token=9c54e168-1443-429d-bee5-c206942e605e

How to implement timeline seeking

To allow the user to seek or jump to a specific point in the video, listen for a click event on the timeline and use the following handler:

...
// This is when clicking on the timeline
const seekToPosition = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const { left, width } = event.currentTarget.getBoundingClientRect();
const clickedPos = (event.clientX - left) / width;
if (clickedPos < 0 || clickedPos > 1) return;

const durationMs = videoRef.current!.duration * 1000 || 0;

const newElapsedMs = durationMs * clickedPos;
const newTimeSec = newElapsedMs / 1000;
videoRef.current.currentTime = newTimeSec;
};
...

From the click event, obtain the dimension from zero to where the timeline was clicked (represented as left) and the width of the timeline. Use these values to calculate the position. Make sure that the click position is within the range `0=<pos<=1`, and if not, return void.

Use the value for `clickedPos` to calculate the new time in seconds and update the video’s `currentTime` attribute with it.

Add controls to the video player

The play controls include play/pause, playback speed, and time tracker. Create components for `PlayIcon.tsx`, `PauseIcon.tsx`, and `ElapsedTimeTracker.tsx`.
- `PlayIcon.tsx` and `PauseIcon.tsx` are going to be SVG icon components
- `ElapsedTimeTracker.tsx` will be a normal react component that returns JSX for the time tracker.

Here are the `PlayIcon.tsx` and `PauseIcon.tsx` icon components:

// PlayIcon.tsx
import React from "react";

export default function Play({
fill = "#FFF",
...rest
}: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.578 12.946C15.2954 14.1729 11.805 16.249 10.0193 16.9156C9.73432 17.0217 9.00774 17.2465 8.85116 17.2496C8.51865 17.259 8.19846 17.0935 8.04365 16.8282C7.98383 16.7251 7.8079 16.0523 7.7516 15.7526C7.58447 14.841 7.49827 13.4252 7.50003 12.0016C7.49827 10.5077 7.59151 9.02478 7.77799 8.12253C7.82725 7.87902 7.97152 7.31862 8.01374 7.22809C8.09291 7.0595 8.23717 6.92682 8.41134 6.84408C8.54504 6.78008 8.6981 6.74574 8.85116 6.75042C9.00774 6.75355 9.6446 6.95179 9.86275 7.02828C11.5833 7.62145 15.2233 9.77249 16.5604 11.0369C16.68 11.1508 17.009 11.4568 17.0635 11.5192C17.1884 11.6597 17.25 11.8299 17.25 12.0016C17.25 12.1608 17.1955 12.3231 17.0829 12.459C17.0248 12.5292 16.6888 12.8414 16.578 12.946Z"
fill={fill}
/>
</svg>
);
}
// PauseIcon.tsx
import React from "react";

export default function Pause({
fill = "#fff",
...rest
}: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<rect x="7" y="6" width="3" height="12" rx="1.5" fill={fill} />
<rect
opacity="0.5"
x="14"
y="6"
width="3"
height="12"
rx="1.5"
fill={fill}
/>
</svg>
);
}

The icon components accept an optional fill prop and spread the rest of the incoming props on the `<svg>` tag.

Now, create another component for the elapsed time tracker.

// ElapsedTimeTracker
import { Flex, Text } from "@chakra-ui/react";
import React from "react";

type Props = {
elapsedSec: number;
totalSec: number | null;
};

export default function ElapsedTimeTracker({ ...props }: Props) {
const elapsedMin = Math.floor(props.elapsedSec / 60);
const elapsedSec = Math.floor(props.elapsedSec % 60);

return (
<Flex
align="center"
className="elapsed-time-tracker"
fontWeight="600"
gap="4px"
transition="500ms opacity"
mt="-8px"
p={0}
>
<Flex justify="end">
<Text fontWeight={600} color="white">
{elapsedMin}:
</Text>
<Text fontWeight={600} color="white">
{elapsedSec.toString().padStart(2, "0")}
</Text>
</Flex>
</Flex>
);
}

The time tracker expects two props: `elapsedSec`, for elapsed time in seconds, and `totalSec` for the video’s total duration in seconds.

Playback rate (or playback speed):

The playback rate is the speed at which a video plays. In this player, the user will be able to increase the speed, but not decrease it. This is the final feature of the player.

// PlaybackRate.tsx
import { CheckIcon, ChevronDownIcon } from "@chakra-ui/icons";
import {
Flex,
Menu,
MenuButton,
MenuGroup,
MenuItem,
MenuList,
Text
} from "@chakra-ui/react";
import React from "react";

type PlaybackRateControlButtonProps = {
onClick?: React.MouseEventHandler;
playbackRate: number;
};

const PlaybackRateControlButton = React.forwardRef<
HTMLDivElement,
PlaybackRateControlButtonProps
>(({ onClick, playbackRate }, ref) => (
<div ref={ref}>
<Flex
className="playback-rate-control"
alignItems="center"
cursor="pointer"
h="40px"
justifyContent="center"
rounded="12px"
w="40px"
_hover={{
bg: "rgba(255, 255, 255, 0.08)"
}}
onClick={onClick}
mt="-8px"
transition="500ms opacity"
>
<Text
color="white"
fontWeight={700}
letterSpacing="0.5px"
pos="relative"
top="-1px"
>
<span style={{ fontSize: "14px" }}>{playbackRate}</span>
<span style={{ fontSize: "11px" }}>x</span>
<ChevronDownIcon
bottom="-1px"
color="white"
marginLeft="-1px"
marginRight="-4px"
opacity="0.5"
pos="relative"
width="12px"
stroke="white"
/>
</Text>
</Flex>
</div>
));

type Props = {
playbackRate: number;
setPlaybackRate: (playbackRate: number) => void;
};

export default React.memo(function PlaybackRateControl({
playbackRate,
setPlaybackRate
}: Props) {
return (
<Menu autoSelect={false} placement="top-start">
<MenuButton as={PlaybackRateControlButton} playbackRate={playbackRate} />
<MenuList
bg="#1D253F"
border="none"
pl="8px"
pr="8px"
minW="50px"
zIndex="2"
>
<MenuGroup
color="white"
fontSize="12px"
fontWeight="400"
ml="12px"
title="Playback Speed"
>
{[1, 1.5, 2].map((rate) => (
<MenuItem
height="40px"
justifyContent="space-between"
key={`playbackRate_${rate}`}
onClick={() => {
if (playbackRate === rate) return;
setPlaybackRate(rate);
}}
rounded="8px"
_hover={{
bg: "rgba(0, 0, 0, 0.4)"
}}
_focus={{
bg: "rgba(0, 0, 0, 0.4)"
}}
>
<Text fontWeight={600} size="sm" color="white">
{rate.toFixed(1)}x
</Text>
{playbackRate === rate && (
<CheckIcon width="15px" height="11px" fill="white" />
)}
</MenuItem>
))}
</MenuGroup>
</MenuList>
</Menu>
);
});

The component expects two props: `playbackRate` and `setPlaybackRate`. `playbackRate` is the state variable for the playback speed, and `setPlaybackRate` is used to set the value of `playbackRate`.

Finalize Video player
To complete the video player, import the components you just created into the `Player.tsx` file and render them there.

The final Player component should have the following code:

// Player.tsx
import { Button, Flex, Spinner } from "@chakra-ui/react";
import styled from "@emotion/styled";
import React, { useEffect, useRef, useState } from "react";
import PlayIcon from "./PlayIcon";
import PauseIcon from "./PauseIcon";
import ElapsedTimeTracker from "./ElapsedTimeTracker";
import PlaybackRate from "./PlaybackRate";

const Video = styled.video`
flex-shrink: 1;
height: 100%;
object-fit: cover;
border-radius: 10px;
`;

interface Props {
src: string;
muted?: boolean;
autoPlay?: boolean;
}

const Player = (props: Props) => {
const { src, autoPlay, muted } = props;
const [isWaiting, setIsWaiting] = useState(false);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [playbackRate, setPlaybackRate] = useState(1);
const [durationSec, setDurationSec] = useState(1);
const [elapsedSec, setElapsedSec] = useState(1);

const videoRef = useRef<HTMLVideoElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const bufferRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!videoRef.current) {
return;
}

const onWaiting = () => {
if (isPlaying) setIsPlaying(false);
setIsWaiting(true);
};

const onPlay = () => {
if (isWaiting) setIsWaiting(false);
setIsPlaying(true);
};

const onPause = () => {
setIsPlaying(false);
setIsWaiting(false);
};

const element = videoRef.current;

element.addEventListener("progress", function () {
if (!element.buffered) return;
const bufferedEnd = element.buffered.end(element.buffered.length - 1);
const duration = element.duration;
if (bufferRef && duration > 0) {
bufferRef.current!.style.width = (bufferedEnd / duration) * 100 + "%";
}
});

element.addEventListener("timeupdate", function () {
setIsWaiting(false);
const duration = element.duration;
setDurationSec(duration);
setElapsedSec(element.currentTime);
if (progressRef && duration > 0) {
progressRef.current!.style.width =
(element.currentTime / duration) * 100 + "%";
}
});

element.addEventListener("waiting", onWaiting);
element.addEventListener("play", onPlay);
element.addEventListener("playing", onPlay);
element.addEventListener("pause", onPause);

// clean up
return () => {
element.removeEventListener("waiting", onWaiting);
element.removeEventListener("play", onPlay);
element.removeEventListener("playing", onPlay);
element.removeEventListener("pause", onPause);
};
}, [videoRef.current]);

// This is where the playback rate is set on the video element.
useEffect(() => {
if (!videoRef.current) return;
if (videoRef.current.playbackRate === playbackRate) return;
videoRef.current.playbackRate = playbackRate;
}, [playbackRate]);

const handlePlayPauseClick = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}
};

const seekToPosition = (pos: number) => {
if (pos < 0 || pos > 1) return;

const durationMs = videoRef.current!.duration * 1000 || 0;

const newElapsedMs = durationMs * pos;
const newTimeSec = newElapsedMs / 1000;
console.log(durationMs, newElapsedMs, pos, newTimeSec);
videoRef.current.currentTime = newTimeSec;
};

return (
<Flex
flexDir="column"
cursor="pointer"
align="center"
justify="center"
pos="relative"
rounded="10px"
overflow="hidden"
_hover={{
".timeline-container": {
opacity: 1
}
}}
>
{isWaiting && <Spinner pos="absolute" color="white" />}
<Video
autoPlay={autoPlay}
muted={muted}
src={src}
onClick={handlePlayPauseClick}
ref={videoRef}
/>
<Flex
w="full"
h="100px"
bg="linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5))"
pos="absolute"
opacity={0}
transition="opacity 0.5s linear"
className="timeline-container"
left={0}
bottom={0}
align="flex-end"
px="1rem"
>
<Flex flexDir="column" w="full" align="center">
<Flex
w="full"
transition="height 0.1s linear"
className="timeline"
h="4px"
mb="0.5rem"
rounded="10px"
bg="rgba(193, 193, 193, 0.5)" // Buffer color: #FDFFFC
_hover={{ height: "5px" }}
overflow="hidden"
onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const { left, width } = e.currentTarget.getBoundingClientRect();
const clickedPos = (e.clientX - left) / width;
seekToPosition(clickedPos);
}}
>
<Flex pos="relative" w="full" h="full">
<Flex
h="full"
className="play-progress"
bg="#0CAADC"
zIndex={1}
ref={progressRef}
/>
<Flex
pos="absolute"
h="full"
className="buffer-progress"
bg="#FDFFFC"
ref={bufferRef}
/>
</Flex>
</Flex>
<Flex w="full" justify="space-between" align="center">
<Flex align="center">
<Button
maxW="25px"
minW="25px"
w="25px"
p="0"
mr="0.4rem"
maxH="25px"
h="25px"
rounded="4px"
colorScheme="transparent"
bg="transparent"
mb="0.5rem"
_hover={{
bg: "rgba(0, 0, 0, 0.4)"
}}
onClick={handlePlayPauseClick}
>
{!isPlaying ? <PlayIcon /> : <PauseIcon />}
</Button>

<ElapsedTimeTracker
totalSec={durationSec}
elapsedSec={elapsedSec}
/>
</Flex>

<PlaybackRate
playbackRate={playbackRate}
setPlaybackRate={setPlaybackRate}
/>
</Flex>
</Flex>
</Flex>
</Flex>
);
};

export default Player;

The final version of the video player should include play/pause, buffering, a timeline, seeking, and player controls, as shown in the screenshot below:

![Video player with buffering](https://firebasestorage.googleapis.com/v0/b/oauth-303809.appspot.com/o/article%2F1295518b-759f-48d0-a799-9060d5cb2a45-final%20video%20player.png?alt=media&token=4aedc5d1-cd79-4b02-9e89-70435739b226)

..
![Video player with buffering and playback rate](https://firebasestorage.googleapis.com/v0/b/oauth-303809.appspot.com/o/article%2F9f68f394-955f-4e9d-92fb-76010e653c80-final%20video%20player%202.png?alt=media&token=a60c4162-401c-48b1-ae3e-dc098bb0ed1e)

Conclusion

With that, we have completed our custom video player with buffering functionality.

If you found this article helpful, consider signing up for our newsletter to receive tips on web development. You can also check out my YouTube channel, FullStack Mastery, for more full-stack development content.

Now it’s your turn — what do you think of the article and our player? Let me know in the comments below.

Thank you!

Helpful links
- Video and Audio APIs
- Simple video buffering loader in React
- YouTube: How to create a React.js video player with buffering | playback speed | scrubbing

--

--