Progress bar
Video progress bar on the top or bottom edge, split into chapter segments, with the current chapter's name.
ProgressBar, chapters
Installation
npx shadcn add https://www.reelcn.dev/r/progress-bar.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: remotion.
Usage
<Sequence durationInFrames={900}>
<ProgressBar chapters={[{ at: 0, label: "Intro" }, { at: 240, label: "Setup" }, { at: 600, label: "Result" }]} />
</Sequence>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.edge"top" | "bottom" Default
"bottom".totalnumberFrames the bar takes to fill. Defaults to the length of its parent `<Sequence>` (or the composition). Inside a
Sequence `useCurrentFrame()` is local, so the bar measures its own Sequence, not the whole video: put it at the
top level of the composition to track the full video.
chaptersProgressBarChapter[]Chapter starts; each one becomes a segment of the bar. Default
[].thicknessnumberBar thickness in design units. Default
8.offsetnumberDistance from the edge in design units. Default
0.showLabelbooleanShow the current chapter's name next to the bar. Default
true.colorstringFill color. Defaults to the theme accent.
trackColorstringUnfilled track. Defaults to the theme foreground at 20%.
labelColorstringChapter label text. Defaults to the theme foreground.
labelBackgroundstringChapter label pill. Defaults to the theme surface at 85%.
styleCSSProperties
classNamestring
Built from
Use
- Tutorials, explainers and long-form edits with named chapters
- Showing viewers how much of a short is left
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/progress-bar.tsx
/**
* @title Progress Bar
* @category overlays
* @description Video progress bar on the top or bottom edge, split into chapter segments, with the current chapter's name.
* @duration data-driven
* @use Tutorials, explainers and long-form edits with named chapters
* @use Showing viewers how much of a short is left
* @tags progress, chapters, timeline, playback, retention
* @example
* <Sequence durationInFrames={900}>
* <ProgressBar chapters={[{ at: 0, label: "Intro" }, { at: 240, label: "Setup" }, { at: 600, label: "Result" }]} />
* </Sequence>
*/
import type React from "react";
import { AbsoluteFill } from "remotion";
import { alpha, type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";
export type ProgressBarChapter = {
/** First frame of the chapter, counted from the start of the bar's own Sequence. */
at: number;
label: string;
};
export type ProgressBarProps = MotionProps & {
edge?: "top" | "bottom";
/**
* Frames the bar takes to fill. Defaults to the length of its parent `<Sequence>` (or the composition). Inside a
* Sequence `useCurrentFrame()` is local, so the bar measures its own Sequence, not the whole video: put it at the
* top level of the composition to track the full video.
*/
total?: number;
/** Chapter starts; each one becomes a segment of the bar. */
chapters?: ProgressBarChapter[];
/** Bar thickness in design units. */
thickness?: number;
/** Distance from the edge in design units. */
offset?: number;
/** Show the current chapter's name next to the bar. */
showLabel?: boolean;
/** Fill color. Defaults to the theme accent. */
color?: string;
/** Unfilled track. Defaults to the theme foreground at 20%. */
trackColor?: string;
/** Chapter label text. Defaults to the theme foreground. */
labelColor?: string;
/** Chapter label pill. Defaults to the theme surface at 85%. */
labelBackground?: string;
style?: React.CSSProperties;
className?: string;
};
const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);
export function ProgressBar({
edge = "bottom",
total,
chapters = [],
thickness = 8,
offset = 0,
showLabel = true,
color,
trackColor,
labelColor,
labelBackground,
style,
className,
...motion
}: ProgressBarProps) {
const theme = useTheme();
const { u, safe } = useViewport();
const m = useMotion(motion);
// Full on the last frame: frame `last` of a `last + 1` frame Sequence.
const last = Math.max((total ?? m.durationInFrames) - 1, 1);
const played = clamp01(m.frame / last);
const sorted = chapters.slice().sort((a, b) => a.at - b.at);
// Time before the first chapter still needs a segment; it just has no name.
const starts = sorted.length === 0 || sorted[0].at > 0 ? [{ at: 0, label: "" }].concat(sorted) : sorted;
const segments = starts.map((chapter, i) => ({
start: clamp01(chapter.at / last),
end: i + 1 < starts.length ? clamp01(starts[i + 1].at / last) : 1,
}));
const current = starts.reduce((found, chapter, i) => (m.frame >= chapter.at ? i : found), 0);
const chapterNumber = sorted.indexOf(starts[current]) + 1;
const shown = clamp01(m.enter);
const labelIn = tween(m.frame, m.fps, {
from: Math.max(starts[current].at, m.delay),
duration: Math.round(m.enterFrames * 0.7),
motion: m.preset,
});
const fill = color ?? theme.colors.accent;
const track = trackColor ?? alpha(theme.colors.foreground, 0.2);
const rounded = offset > 0 ? u(thickness / 2) : 0;
const fromEdge = (distance: number) => (edge === "top" ? { top: u(distance) } : { bottom: u(distance) });
return (
<AbsoluteFill
className={className}
style={{ pointerEvents: "none", fontFamily: theme.fonts.body, opacity: 1 - m.exit, ...style }}
>
<div
style={{
position: "absolute",
left: 0,
right: 0,
...fromEdge(offset),
height: u(thickness),
display: "flex",
gap: segments.length > 1 ? u(4) : 0,
scale: `1 ${shown}`,
transformOrigin: edge,
}}
>
{segments.map((segment, i) => {
const span = segment.end - segment.start;
const filled = span > 0 ? clamp01((played - segment.start) / span) : 1;
return (
<div
key={i}
style={{
flex: `${Math.max(span, 0)} 1 0`,
position: "relative",
overflow: "hidden",
borderRadius: rounded,
background: track,
}}
>
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${filled * 100}%`,
background: fill,
}}
/>
</div>
);
})}
</div>
{showLabel && chapterNumber > 0 && (
<div
style={{
position: "absolute",
left: safe.x,
...fromEdge(offset + thickness + 20),
display: "flex",
alignItems: "center",
gap: u(12),
padding: `${u(10)}px ${u(20)}px`,
borderRadius: u(999),
background: labelBackground ?? alpha(theme.colors.surface, 0.85),
color: labelColor ?? theme.colors.foreground,
fontSize: u(26),
fontWeight: 600,
opacity: clamp01(labelIn) * shown,
translate: `0 ${(1 - labelIn) * u(10) * (edge === "top" ? -1 : 1)}px`,
}}
>
<span style={{ opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>
{chapterNumber}/{sorted.length}
</span>
<span>{starts[current].label}</span>
</div>
)}
</AbsoluteFill>
);
}