Skip to main content

Custom controls for the Player

You may want to implement custom controls for the <Player> component.

There are two approaches:

  • Enable the controls prop and granunarly override some or all of the controls inside the Player.
  • Disable the controls prop and implement your own controls anywhere on the page.

Custom inline controls

Use this approach if you:

  • Like the default controls but want to customize some of them
  • Want the controls to overlay the Player.

Ensure the controls prop is set in the <Player/>.
Use the following APIs to customize the individual controls:

Controls outside the Player

Use this approach if you:

  • Want to implement custom controls anywhere on the page
  • Want full control over the look and behavior of the controls

Use the following starting points to implement your own controls. You will need the following prerequisites:

  • Ensure the controls prop is not set in the <Player/>.
  • Obtain a ref of type PlayerRef of the <Player/>.
  • Some of the components will require durationInFrames and fps props. Place the values in shared variables to be used in both <Player/> and these components.
  • The <SeekBar/> component can optionally accept inFrame and outFrame props. They're the same values passed to <Player/> (also optional).

Play / Pause button

PlayPauseButton.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import {useCallback, useEffect, useState} from 'react';
 
export const PlayPauseButton: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [playing, setPlaying] = useState(false);
 
useEffect(() => {
const {current} = playerRef;
setPlaying(current?.isPlaying() ?? false);
if (!current) return;
 
const onPlay = () => {
setPlaying(true);
};
 
const onPause = () => {
setPlaying(false);
};
 
current.addEventListener('play', onPlay);
current.addEventListener('pause', onPause);
 
return () => {
current.removeEventListener('play', onPlay);
current.removeEventListener('pause', onPause);
};
}, [playerRef]);
 
const onToggle = useCallback(() => {
playerRef.current?.toggle();
}, [playerRef]);
 
return (
<button onClick={onToggle} type="button">
{playing ? 'Pause' : 'Play'}
</button>
);
};
PlayPauseButton.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import {useCallback, useEffect, useState} from 'react';
 
export const PlayPauseButton: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [playing, setPlaying] = useState(false);
 
useEffect(() => {
const {current} = playerRef;
setPlaying(current?.isPlaying() ?? false);
if (!current) return;
 
const onPlay = () => {
setPlaying(true);
};
 
const onPause = () => {
setPlaying(false);
};
 
current.addEventListener('play', onPlay);
current.addEventListener('pause', onPause);
 
return () => {
current.removeEventListener('play', onPlay);
current.removeEventListener('pause', onPause);
};
}, [playerRef]);
 
const onToggle = useCallback(() => {
playerRef.current?.toggle();
}, [playerRef]);
 
return (
<button onClick={onToggle} type="button">
{playing ? 'Pause' : 'Play'}
</button>
);
};
note

The buffering indicator is not implemented in this snippet.

Time display

TimeDisplay.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useEffect} from 'react';
 
export const formatTime = (frame: number, fps: number): string => {
const hours = Math.floor(frame / fps / 3600);
 
const remainingMinutes = frame - hours * fps * 3600;
const minutes = Math.floor(remainingMinutes / 60 / fps);
 
const remainingSec = frame - hours * fps * 3600 - minutes * fps * 60;
const seconds = Math.floor(remainingSec / fps);
 
const frameAfterSec = Math.round(frame % fps);
 
const hoursStr = String(hours);
const minutesStr = String(minutes).padStart(2, '0');
const secondsStr = String(seconds).padStart(2, '0');
const frameStr = String(frameAfterSec).padStart(2, '0');
 
if (hours > 0) {
return `${hoursStr}:${minutesStr}:${secondsStr}.${frameStr}`;
}
 
return `${minutesStr}:${secondsStr}.${frameStr}`;
};
 
export const TimeDisplay: React.FC<{
durationInFrames: number;
fps: number;
playerRef: React.RefObject<PlayerRef>;
}> = ({durationInFrames, fps, playerRef}) => {
const [time, setTime] = React.useState(0);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onTimeUpdate = () => {
setTime(current.getCurrentFrame());
};
 
current.addEventListener('frameupdate', onTimeUpdate);
 
return () => {
current.removeEventListener('frameupdate', onTimeUpdate);
};
}, [playerRef]);
 
return (
<div
style={{
fontFamily: 'monospace',
}}
>
<span>
{formatTime(time, fps)}/{formatTime(durationInFrames, fps)}
</span>
</div>
);
};
TimeDisplay.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useEffect} from 'react';
 
export const formatTime = (frame: number, fps: number): string => {
const hours = Math.floor(frame / fps / 3600);
 
const remainingMinutes = frame - hours * fps * 3600;
const minutes = Math.floor(remainingMinutes / 60 / fps);
 
const remainingSec = frame - hours * fps * 3600 - minutes * fps * 60;
const seconds = Math.floor(remainingSec / fps);
 
const frameAfterSec = Math.round(frame % fps);
 
const hoursStr = String(hours);
const minutesStr = String(minutes).padStart(2, '0');
const secondsStr = String(seconds).padStart(2, '0');
const frameStr = String(frameAfterSec).padStart(2, '0');
 
if (hours > 0) {
return `${hoursStr}:${minutesStr}:${secondsStr}.${frameStr}`;
}
 
return `${minutesStr}:${secondsStr}.${frameStr}`;
};
 
export const TimeDisplay: React.FC<{
durationInFrames: number;
fps: number;
playerRef: React.RefObject<PlayerRef>;
}> = ({durationInFrames, fps, playerRef}) => {
const [time, setTime] = React.useState(0);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onTimeUpdate = () => {
setTime(current.getCurrentFrame());
};
 
current.addEventListener('frameupdate', onTimeUpdate);
 
return () => {
current.removeEventListener('frameupdate', onTimeUpdate);
};
}, [playerRef]);
 
return (
<div
style={{
fontFamily: 'monospace',
}}
>
<span>
{formatTime(time, fps)}/{formatTime(durationInFrames, fps)}
</span>
</div>
);
};
note

The conventional time formatting for video editors is hh:mm:ss.ff where hh is hours, mm is minutes, ss is seconds and ff is frames past the second.

Fullscreen button

Pay attention to two nuances when implementing the Fullscreen button:

  • Not all browsers support Fullscreen, feature detection should be performed.
  • If using server-side rendering, feature detection should be performed after the component has been mounted on the client to avoid a React hydration mismatch.
FullscreenButton.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useCallback, useEffect, useState} from 'react';
 
export const FullscreenButton: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [supportsFullscreen, setSupportsFullscreen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
 
useEffect(() => {
const {current} = playerRef;
 
if (!current) {
return;
}
 
const onFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement !== null);
};
 
current.addEventListener('fullscreenchange', onFullscreenChange);
 
return () => {
current.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, [playerRef]);
 
useEffect(() => {
// Must be handled client-side to avoid SSR hydration mismatch
setSupportsFullscreen(
(typeof document !== 'undefined' &&
(document.fullscreenEnabled ||
// @ts-expect-error Types not defined
document.webkitFullscreenEnabled)) ??
false,
);
}, []);
 
const onClick = useCallback(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
if (isFullscreen) {
current.exitFullscreen();
} else {
current.requestFullscreen();
}
}, [isFullscreen, playerRef]);
 
if (!supportsFullscreen) {
return null;
}
 
return (
<button type="button" onClick={onClick}>
{isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
</button>
);
};
FullscreenButton.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useCallback, useEffect, useState} from 'react';
 
export const FullscreenButton: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [supportsFullscreen, setSupportsFullscreen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
 
useEffect(() => {
const {current} = playerRef;
 
if (!current) {
return;
}
 
const onFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement !== null);
};
 
current.addEventListener('fullscreenchange', onFullscreenChange);
 
return () => {
current.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, [playerRef]);
 
useEffect(() => {
// Must be handled client-side to avoid SSR hydration mismatch
setSupportsFullscreen(
(typeof document !== 'undefined' &&
(document.fullscreenEnabled ||
// @ts-expect-error Types not defined
document.webkitFullscreenEnabled)) ??
false,
);
}, []);
 
const onClick = useCallback(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
if (isFullscreen) {
current.exitFullscreen();
} else {
current.requestFullscreen();
}
}, [isFullscreen, playerRef]);
 
if (!supportsFullscreen) {
return null;
}
 
return (
<button type="button" onClick={onClick}>
{isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
</button>
);
};
note

The Exit Fullscreen label is hypothetical since if it is rendered outside of the Player, it would not be visible while in Fullscreen.

Seek bar

SeekBar.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {interpolate} from 'remotion';
 
type Size = {
width: number;
height: number;
left: number;
top: number;
};
 
// If a pane has been moved, it will cause a layout shift without
// the window having been resized. Those UI elements can call this API to
// force an update
 
export const useElementSize = (
ref: React.RefObject<HTMLElement>,
): Size | null => {
const [size, setSize] = useState<Size | null>(() => {
if (!ref.current) {
return null;
}
 
const rect = ref.current.getClientRects();
if (!rect[0]) {
return null;
}
 
return {
width: rect[0].width as number,
height: rect[0].height as number,
left: rect[0].x as number,
top: rect[0].y as number,
};
});
 
const observer = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
 
return new ResizeObserver((entries) => {
const {target} = entries[0];
const newSize = target.getClientRects();
 
if (!newSize?.[0]) {
setSize(null);
return;
}
 
const {width} = newSize[0];
 
const {height} = newSize[0];
 
setSize({
width,
height,
left: newSize[0].x,
top: newSize[0].y,
});
});
}, []);
 
const updateSize = useCallback(() => {
if (!ref.current) {
return;
}
 
const rect = ref.current.getClientRects();
if (!rect[0]) {
setSize(null);
return;
}
 
setSize((prevState) => {
const isSame =
prevState &&
prevState.width === rect[0].width &&
prevState.height === rect[0].height &&
prevState.left === rect[0].x &&
prevState.top === rect[0].y;
if (isSame) {
return prevState;
}
 
return {
width: rect[0].width as number,
height: rect[0].height as number,
left: rect[0].x as number,
top: rect[0].y as number,
windowSize: {
height: window.innerHeight,
width: window.innerWidth,
},
};
});
}, [ref]);
 
useEffect(() => {
if (!observer) {
return;
}
 
const {current} = ref;
if (current) {
observer.observe(current);
}
 
return (): void => {
if (current) {
observer.unobserve(current);
}
};
}, [observer, ref, updateSize]);
 
useEffect(() => {
window.addEventListener('resize', updateSize);
 
return () => {
window.removeEventListener('resize', updateSize);
};
}, [updateSize]);
 
return useMemo(() => {
if (!size) {
return null;
}
 
return {...size, refresh: updateSize};
}, [size, updateSize]);
};
 
const getFrameFromX = (
clientX: number,
durationInFrames: number,
width: number,
) => {
const pos = clientX;
const frame = Math.round(
interpolate(pos, [0, width], [0, Math.max(durationInFrames - 1, 0)], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
}),
);
return frame;
};
 
const BAR_HEIGHT = 5;
const KNOB_SIZE = 12;
const VERTICAL_PADDING = 4;
 
const containerStyle: React.CSSProperties = {
userSelect: 'none',
WebkitUserSelect: 'none',
paddingTop: VERTICAL_PADDING,
paddingBottom: VERTICAL_PADDING,
boxSizing: 'border-box',
cursor: 'pointer',
position: 'relative',
touchAction: 'none',
flex: 1,
};
 
const barBackground: React.CSSProperties = {
height: BAR_HEIGHT,
backgroundColor: 'rgba(0, 0, 0, 0.25)',
width: '100%',
borderRadius: BAR_HEIGHT / 2,
};
 
const findBodyInWhichDivIsLocated = (div: HTMLElement) => {
let current = div;
 
while (current.parentElement) {
current = current.parentElement;
}
 
return current;
};
 
export const useHoverState = (ref: React.RefObject<HTMLDivElement>) => {
const [hovered, setHovered] = useState(false);
 
useEffect(() => {
const {current} = ref;
if (!current) {
return;
}
 
const onHover = () => {
setHovered(true);
};
 
const onLeave = () => {
setHovered(false);
};
 
const onMove = () => {
setHovered(true);
};
 
current.addEventListener('mouseenter', onHover);
current.addEventListener('mouseleave', onLeave);
current.addEventListener('mousemove', onMove);
 
return () => {
current.removeEventListener('mouseenter', onHover);
current.removeEventListener('mouseleave', onLeave);
current.removeEventListener('mousemove', onMove);
};
}, [ref]);
return hovered;
};
 
export const SeekBar: React.FC<{
durationInFrames: number;
inFrame?: number | null;
outFrame?: number | null;
playerRef: React.RefObject<PlayerRef>;
}> = ({durationInFrames, inFrame, outFrame, playerRef}) => {
const containerRef = useRef<HTMLDivElement>(null);
const barHovered = useHoverState(containerRef);
const size = useElementSize(containerRef);
const [playing, setPlaying] = useState(false);
const [frame, setFrame] = useState(0);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onFrameUpdate = () => {
setFrame(current.getCurrentFrame());
};
 
current.addEventListener('frameupdate', onFrameUpdate);
 
return () => {
current.removeEventListener('frameupdate', onFrameUpdate);
};
}, [playerRef]);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onPlay = () => {
setPlaying(true);
};
 
const onPause = () => {
setPlaying(false);
};
 
current.addEventListener('play', onPlay);
current.addEventListener('pause', onPause);
 
return () => {
current.removeEventListener('play', onPlay);
current.removeEventListener('pause', onPause);
};
}, [playerRef]);
 
const [dragging, setDragging] = useState<
| {
dragging: false;
}
| {
dragging: true;
wasPlaying: boolean;
}
>({
dragging: false,
});
 
const width = size?.width ?? 0;
 
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) {
return;
}
 
if (!playerRef.current) {
return;
}
 
const posLeft = containerRef.current?.getBoundingClientRect()
.left as number;
 
const _frame = getFrameFromX(
e.clientX - posLeft,
durationInFrames,
width,
);
playerRef.current.pause();
playerRef.current.seekTo(_frame);
setDragging({
dragging: true,
wasPlaying: playing,
});
},
[durationInFrames, width, playerRef, playing],
);
 
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!size) {
throw new Error('Player has no size');
}
 
if (!dragging.dragging) {
return;
}
 
if (!playerRef.current) {
return;
}
 
const posLeft = containerRef.current?.getBoundingClientRect()
.left as number;
 
const _frame = getFrameFromX(
e.clientX - posLeft,
durationInFrames,
size.width,
);
playerRef.current.seekTo(_frame);
},
[dragging.dragging, durationInFrames, playerRef, size],
);
 
const onPointerUp = useCallback(() => {
setDragging({
dragging: false,
});
if (!dragging.dragging) {
return;
}
 
if (!playerRef.current) {
return;
}
 
if (dragging.wasPlaying) {
playerRef.current.play();
} else {
playerRef.current.pause();
}
}, [dragging, playerRef]);
 
useEffect(() => {
if (!dragging.dragging) {
return;
}
 
const body = findBodyInWhichDivIsLocated(
containerRef.current as HTMLElement,
);
 
body.addEventListener('pointermove', onPointerMove);
body.addEventListener('pointerup', onPointerUp);
return () => {
body.removeEventListener('pointermove', onPointerMove);
body.removeEventListener('pointerup', onPointerUp);
};
}, [dragging.dragging, onPointerMove, onPointerUp]);
 
const knobStyle: React.CSSProperties = useMemo(() => {
return {
height: KNOB_SIZE,
width: KNOB_SIZE,
borderRadius: KNOB_SIZE / 2,
position: 'absolute',
top: VERTICAL_PADDING - KNOB_SIZE / 2 + 5 / 2,
backgroundColor: '#000',
left: Math.max(
0,
(frame / Math.max(1, durationInFrames - 1)) * width - KNOB_SIZE / 2,
),
boxShadow: '0 0 2px black',
opacity: Number(barHovered),
transition: 'opacity 0.1s ease',
};
}, [barHovered, durationInFrames, frame, width]);
 
const fillStyle: React.CSSProperties = useMemo(() => {
return {
height: BAR_HEIGHT,
backgroundColor: '#000',
width: ((frame - (inFrame ?? 0)) / (durationInFrames - 1)) * 100 + '%',
marginLeft: ((inFrame ?? 0) / (durationInFrames - 1)) * 100 + '%',
borderRadius: BAR_HEIGHT / 2,
};
}, [durationInFrames, frame, inFrame]);
 
const active: React.CSSProperties = useMemo(() => {
return {
height: BAR_HEIGHT,
backgroundColor: '#000',
opacity: 0.6,
width:
(((outFrame ?? durationInFrames - 1) - (inFrame ?? 0)) /
(durationInFrames - 1)) *
100 +
'%',
marginLeft: ((inFrame ?? 0) / (durationInFrames - 1)) * 100 + '%',
borderRadius: BAR_HEIGHT / 2,
position: 'absolute',
};
}, [durationInFrames, inFrame, outFrame]);
 
return (
<div
ref={containerRef}
onPointerDown={onPointerDown}
style={containerStyle}
>
<div style={barBackground}>
<div style={active} />
<div style={fillStyle} />
</div>
<div style={knobStyle} />
</div>
);
};
SeekBar.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {interpolate} from 'remotion';
 
type Size = {
width: number;
height: number;
left: number;
top: number;
};
 
// If a pane has been moved, it will cause a layout shift without
// the window having been resized. Those UI elements can call this API to
// force an update
 
export const useElementSize = (
ref: React.RefObject<HTMLElement>,
): Size | null => {
const [size, setSize] = useState<Size | null>(() => {
if (!ref.current) {
return null;
}
 
const rect = ref.current.getClientRects();
if (!rect[0]) {
return null;
}
 
return {
width: rect[0].width as number,
height: rect[0].height as number,
left: rect[0].x as number,
top: rect[0].y as number,
};
});
 
const observer = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
 
return new ResizeObserver((entries) => {
const {target} = entries[0];
const newSize = target.getClientRects();
 
if (!newSize?.[0]) {
setSize(null);
return;
}
 
const {width} = newSize[0];
 
const {height} = newSize[0];
 
setSize({
width,
height,
left: newSize[0].x,
top: newSize[0].y,
});
});
}, []);
 
const updateSize = useCallback(() => {
if (!ref.current) {
return;
}
 
const rect = ref.current.getClientRects();
if (!rect[0]) {
setSize(null);
return;
}
 
setSize((prevState) => {
const isSame =
prevState &&
prevState.width === rect[0].width &&
prevState.height === rect[0].height &&
prevState.left === rect[0].x &&
prevState.top === rect[0].y;
if (isSame) {
return prevState;
}
 
return {
width: rect[0].width as number,
height: rect[0].height as number,
left: rect[0].x as number,
top: rect[0].y as number,
windowSize: {
height: window.innerHeight,
width: window.innerWidth,
},
};
});
}, [ref]);
 
useEffect(() => {
if (!observer) {
return;
}
 
const {current} = ref;
if (current) {
observer.observe(current);
}
 
return (): void => {
if (current) {
observer.unobserve(current);
}
};
}, [observer, ref, updateSize]);
 
useEffect(() => {
window.addEventListener('resize', updateSize);
 
return () => {
window.removeEventListener('resize', updateSize);
};
}, [updateSize]);
 
return useMemo(() => {
if (!size) {
return null;
}
 
return {...size, refresh: updateSize};
}, [size, updateSize]);
};
 
const getFrameFromX = (
clientX: number,
durationInFrames: number,
width: number,
) => {
const pos = clientX;
const frame = Math.round(
interpolate(pos, [0, width], [0, Math.max(durationInFrames - 1, 0)], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
}),
);
return frame;
};
 
const BAR_HEIGHT = 5;
const KNOB_SIZE = 12;
const VERTICAL_PADDING = 4;
 
const containerStyle: React.CSSProperties = {
userSelect: 'none',
WebkitUserSelect: 'none',
paddingTop: VERTICAL_PADDING,
paddingBottom: VERTICAL_PADDING,
boxSizing: 'border-box',
cursor: 'pointer',
position: 'relative',
touchAction: 'none',
flex: 1,
};
 
const barBackground: React.CSSProperties = {
height: BAR_HEIGHT,
backgroundColor: 'rgba(0, 0, 0, 0.25)',
width: '100%',
borderRadius: BAR_HEIGHT / 2,
};
 
const findBodyInWhichDivIsLocated = (div: HTMLElement) => {
let current = div;
 
while (current.parentElement) {
current = current.parentElement;
}
 
return current;
};
 
export const useHoverState = (ref: React.RefObject<HTMLDivElement>) => {
const [hovered, setHovered] = useState(false);
 
useEffect(() => {
const {current} = ref;
if (!current) {
return;
}
 
const onHover = () => {
setHovered(true);
};
 
const onLeave = () => {
setHovered(false);
};
 
const onMove = () => {
setHovered(true);
};
 
current.addEventListener('mouseenter', onHover);
current.addEventListener('mouseleave', onLeave);
current.addEventListener('mousemove', onMove);
 
return () => {
current.removeEventListener('mouseenter', onHover);
current.removeEventListener('mouseleave', onLeave);
current.removeEventListener('mousemove', onMove);
};
}, [ref]);
return hovered;
};
 
export const SeekBar: React.FC<{
durationInFrames: number;
inFrame?: number | null;
outFrame?: number | null;
playerRef: React.RefObject<PlayerRef>;
}> = ({durationInFrames, inFrame, outFrame, playerRef}) => {
const containerRef = useRef<HTMLDivElement>(null);
const barHovered = useHoverState(containerRef);
const size = useElementSize(containerRef);
const [playing, setPlaying] = useState(false);
const [frame, setFrame] = useState(0);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onFrameUpdate = () => {
setFrame(current.getCurrentFrame());
};
 
current.addEventListener('frameupdate', onFrameUpdate);
 
return () => {
current.removeEventListener('frameupdate', onFrameUpdate);
};
}, [playerRef]);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onPlay = () => {
setPlaying(true);
};
 
const onPause = () => {
setPlaying(false);
};
 
current.addEventListener('play', onPlay);
current.addEventListener('pause', onPause);
 
return () => {
current.removeEventListener('play', onPlay);
current.removeEventListener('pause', onPause);
};
}, [playerRef]);
 
const [dragging, setDragging] = useState<
| {
dragging: false;
}
| {
dragging: true;
wasPlaying: boolean;
}
>({
dragging: false,
});
 
const width = size?.width ?? 0;
 
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) {
return;
}
 
if (!playerRef.current) {
return;
}
 
const posLeft = containerRef.current?.getBoundingClientRect()
.left as number;
 
const _frame = getFrameFromX(
e.clientX - posLeft,
durationInFrames,
width,
);
playerRef.current.pause();
playerRef.current.seekTo(_frame);
setDragging({
dragging: true,
wasPlaying: playing,
});
},
[durationInFrames, width, playerRef, playing],
);
 
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!size) {
throw new Error('Player has no size');
}
 
if (!dragging.dragging) {
return;
}
 
if (!playerRef.current) {
return;
}
 
const posLeft = containerRef.current?.getBoundingClientRect()
.left as number;
 
const _frame = getFrameFromX(
e.clientX - posLeft,
durationInFrames,
size.width,
);
playerRef.current.seekTo(_frame);
},
[dragging.dragging, durationInFrames, playerRef, size],
);
 
const onPointerUp = useCallback(() => {
setDragging({
dragging: false,
});
if (!dragging.dragging) {
return;
}
 
if (!playerRef.current) {
return;
}
 
if (dragging.wasPlaying) {
playerRef.current.play();
} else {
playerRef.current.pause();
}
}, [dragging, playerRef]);
 
useEffect(() => {
if (!dragging.dragging) {
return;
}
 
const body = findBodyInWhichDivIsLocated(
containerRef.current as HTMLElement,
);
 
body.addEventListener('pointermove', onPointerMove);
body.addEventListener('pointerup', onPointerUp);
return () => {
body.removeEventListener('pointermove', onPointerMove);
body.removeEventListener('pointerup', onPointerUp);
};
}, [dragging.dragging, onPointerMove, onPointerUp]);
 
const knobStyle: React.CSSProperties = useMemo(() => {
return {
height: KNOB_SIZE,
width: KNOB_SIZE,
borderRadius: KNOB_SIZE / 2,
position: 'absolute',
top: VERTICAL_PADDING - KNOB_SIZE / 2 + 5 / 2,
backgroundColor: '#000',
left: Math.max(
0,
(frame / Math.max(1, durationInFrames - 1)) * width - KNOB_SIZE / 2,
),
boxShadow: '0 0 2px black',
opacity: Number(barHovered),
transition: 'opacity 0.1s ease',
};
}, [barHovered, durationInFrames, frame, width]);
 
const fillStyle: React.CSSProperties = useMemo(() => {
return {
height: BAR_HEIGHT,
backgroundColor: '#000',
width: ((frame - (inFrame ?? 0)) / (durationInFrames - 1)) * 100 + '%',
marginLeft: ((inFrame ?? 0) / (durationInFrames - 1)) * 100 + '%',
borderRadius: BAR_HEIGHT / 2,
};
}, [durationInFrames, frame, inFrame]);
 
const active: React.CSSProperties = useMemo(() => {
return {
height: BAR_HEIGHT,
backgroundColor: '#000',
opacity: 0.6,
width:
(((outFrame ?? durationInFrames - 1) - (inFrame ?? 0)) /
(durationInFrames - 1)) *
100 +
'%',
marginLeft: ((inFrame ?? 0) / (durationInFrames - 1)) * 100 + '%',
borderRadius: BAR_HEIGHT / 2,
position: 'absolute',
};
}, [durationInFrames, inFrame, outFrame]);
 
return (
<div
ref={containerRef}
onPointerDown={onPointerDown}
style={containerStyle}
>
<div style={barBackground}>
<div style={active} />
<div style={fillStyle} />
</div>
<div style={knobStyle} />
</div>
);
};

Loop button

loop is a prop of the <Player/> component, so you can just control is using a useState hook.

LoopButton.tsx
tsx
import React from 'react';
 
export const LoopButton: React.FC<{
loop: boolean;
setLoop: React.Dispatch<React.SetStateAction<boolean>>;
}> = ({loop, setLoop}) => {
const onClick = React.useCallback(() => {
setLoop((prev) => !prev);
}, [setLoop]);
 
return (
<button type="button" onClick={onClick}>
{loop ? 'Loop enabled' : 'Loop disabled'}
</button>
);
};
LoopButton.tsx
tsx
import React from 'react';
 
export const LoopButton: React.FC<{
loop: boolean;
setLoop: React.Dispatch<React.SetStateAction<boolean>>;
}> = ({loop, setLoop}) => {
const onClick = React.useCallback(() => {
setLoop((prev) => !prev);
}, [setLoop]);
 
return (
<button type="button" onClick={onClick}>
{loop ? 'Loop enabled' : 'Loop disabled'}
</button>
);
};
Usage
tsx
import React, {useState} from 'react';
import {LoopButton} from './LoopButton';
import {Player} from '@remotion/player';
 
export const MyComponent: React.FC = () => {
const [loop, setLoop] = useState(false);
 
return (
<>
<Player
component={MyComp}
loop={loop}
durationInFrames={100}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
inputProps={{}}
/>
<LoopButton loop={loop} setLoop={setLoop} />
</>
);
};
Usage
tsx
import React, {useState} from 'react';
import {LoopButton} from './LoopButton';
import {Player} from '@remotion/player';
 
export const MyComponent: React.FC = () => {
const [loop, setLoop] = useState(false);
 
return (
<>
<Player
component={MyComp}
loop={loop}
durationInFrames={100}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
inputProps={{}}
/>
<LoopButton loop={loop} setLoop={setLoop} />
</>
);
};

Volume slider

Note that if the video is "muted", the volume state may be greater than 0.
The following component handles the special case of the video being "muted":

  • If the video is muted, set the slider value to 0.
  • If the slider is being slided, unmute the video if necessary.

This allows us to keep an internal state of the volume that was set before muting the video and reset the slider to that value after unmuting.

VolumeSlider.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useEffect, useState} from 'react';
 
export const VolumeSlider: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [volume, setVolume] = useState(playerRef.current?.getVolume() ?? 1);
const [muted, setMuted] = useState(playerRef.current?.isMuted() ?? false);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onVolumeChange = () => {
setVolume(current.getVolume());
};
 
const onMuteChange = () => {
setMuted(current.isMuted());
};
 
current.addEventListener('volumechange', onVolumeChange);
current.addEventListener('mutechange', onMuteChange);
 
return () => {
current.removeEventListener('volumechange', onVolumeChange);
current.removeEventListener('mutechange', onMuteChange);
};
}, [playerRef]);
 
const onChange: React.ChangeEventHandler<HTMLInputElement> =
React.useCallback(
(evt) => {
if (!playerRef.current) {
return;
}
 
const newVolume = Number(evt.target.value);
if (newVolume > 0 && playerRef.current.isMuted()) {
playerRef.current.unmute();
}
 
playerRef.current.setVolume(newVolume);
},
[playerRef],
);
 
return (
<input
value={muted ? 0 : volume}
type="range"
min={0}
max={1}
step={0.01}
onChange={onChange}
/>
);
};
VolumeSlider.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useEffect, useState} from 'react';
 
export const VolumeSlider: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [volume, setVolume] = useState(playerRef.current?.getVolume() ?? 1);
const [muted, setMuted] = useState(playerRef.current?.isMuted() ?? false);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onVolumeChange = () => {
setVolume(current.getVolume());
};
 
const onMuteChange = () => {
setMuted(current.isMuted());
};
 
current.addEventListener('volumechange', onVolumeChange);
current.addEventListener('mutechange', onMuteChange);
 
return () => {
current.removeEventListener('volumechange', onVolumeChange);
current.removeEventListener('mutechange', onMuteChange);
};
}, [playerRef]);
 
const onChange: React.ChangeEventHandler<HTMLInputElement> =
React.useCallback(
(evt) => {
if (!playerRef.current) {
return;
}
 
const newVolume = Number(evt.target.value);
if (newVolume > 0 && playerRef.current.isMuted()) {
playerRef.current.unmute();
}
 
playerRef.current.setVolume(newVolume);
},
[playerRef],
);
 
return (
<input
value={muted ? 0 : volume}
type="range"
min={0}
max={1}
step={0.01}
onChange={onChange}
/>
);
};

Mute button

Remotion also considers a video "muted" if the volume is 0.
You don't need to handle a special case here.

MuteButton.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useEffect, useState} from 'react';
 
export const MuteButton: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [muted, setMuted] = useState(playerRef.current?.isMuted() ?? false);
 
const onClick = React.useCallback(() => {
if (!playerRef.current) {
return;
}
 
if (playerRef.current.isMuted()) {
playerRef.current.unmute();
} else {
playerRef.current.mute();
}
}, [playerRef]);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onMuteChange = () => {
setMuted(current.isMuted());
};
 
current.addEventListener('mutechange', onMuteChange);
return () => {
current.removeEventListener('mutechange', onMuteChange);
};
}, [playerRef]);
 
return (
<button type="button" onClick={onClick}>
{muted ? 'Unmute' : 'Mute'}
</button>
);
};
MuteButton.tsx
tsx
import type {PlayerRef} from '@remotion/player';
import React, {useEffect, useState} from 'react';
 
export const MuteButton: React.FC<{
playerRef: React.RefObject<PlayerRef>;
}> = ({playerRef}) => {
const [muted, setMuted] = useState(playerRef.current?.isMuted() ?? false);
 
const onClick = React.useCallback(() => {
if (!playerRef.current) {
return;
}
 
if (playerRef.current.isMuted()) {
playerRef.current.unmute();
} else {
playerRef.current.mute();
}
}, [playerRef]);
 
useEffect(() => {
const {current} = playerRef;
if (!current) {
return;
}
 
const onMuteChange = () => {
setMuted(current.isMuted());
};
 
current.addEventListener('mutechange', onMuteChange);
return () => {
current.removeEventListener('mutechange', onMuteChange);
};
}, [playerRef]);
 
return (
<button type="button" onClick={onClick}>
{muted ? 'Unmute' : 'Mute'}
</button>
);
};