Timeline editors give visual control over animations. Here is how to build one.
Types
export interface Keyframe {
id: string;
time: number; // 0-100 percentage
properties: Record<string, string | number>;
easing: string;
}
export interface Track {
id: string;
name: string;
property: string;
keyframes: Keyframe[];
color: string;
}
export interface TimelineState {
tracks: Track[];
duration: number; // in milliseconds
currentTime: number;
playing: boolean;
selectedKeyframe: string | null;
}
Timeline Hook
"use client";
import { useReducer, useCallback, useRef, useEffect } from "react";
import type { Track, Keyframe, TimelineState } from "./types";
type Action =
| { type: "SET_TIME"; time: number }
| { type: "PLAY" }
| { type: "PAUSE" }
| { type: "ADD_KEYFRAME"; trackId: string; time: number }
| { type: "MOVE_KEYFRAME"; keyframeId: string; time: number }
| { type: "UPDATE_KEYFRAME"; keyframeId: string; properties: Record<string, string | number> }
| { type: "SELECT_KEYFRAME"; id: string | null }
| { type: "DELETE_KEYFRAME"; id: string }
| { type: "ADD_TRACK"; property: string }
| { type: "REMOVE_TRACK"; id: string };
function timelineReducer(state: TimelineState, action: Action): TimelineState {
switch (action.type) {
case "SET_TIME":
return { ...state, currentTime: Math.max(0, Math.min(100, action.time)) };
case "PLAY":
return { ...state, playing: true };
case "PAUSE":
return { ...state, playing: false };
case "ADD_KEYFRAME": {
const tracks = state.tracks.map((track) => {
if (track.id !== action.trackId) return track;
const newKeyframe: Keyframe = {
id: crypto.randomUUID(),
time: action.time,
properties: { [track.property]: "0" },
easing: "ease",
};
return {
...track,
keyframes: [...track.keyframes, newKeyframe].sort(
(a, b) => a.time - b.time,
),
};
});
return { ...state, tracks };
}
case "MOVE_KEYFRAME": {
const tracks = state.tracks.map((track) => ({
...track,
keyframes: track.keyframes
.map((kf) =>
kf.id === action.keyframeId
? { ...kf, time: Math.max(0, Math.min(100, action.time)) }
: kf,
)
.sort((a, b) => a.time - b.time),
}));
return { ...state, tracks };
}
case "UPDATE_KEYFRAME": {
const tracks = state.tracks.map((track) => ({
...track,
keyframes: track.keyframes.map((kf) =>
kf.id === action.keyframeId
? { ...kf, properties: { ...kf.properties, ...action.properties } }
: kf,
),
}));
return { ...state, tracks };
}
case "SELECT_KEYFRAME":
return { ...state, selectedKeyframe: action.id };
case "DELETE_KEYFRAME": {
const tracks = state.tracks.map((track) => ({
...track,
keyframes: track.keyframes.filter((kf) => kf.id !== action.id),
}));
return { ...state, tracks, selectedKeyframe: null };
}
case "ADD_TRACK": {
const colors = ["#3b82f6", "#ef4444", "#22c55e", "#eab308", "#a855f7"];
const newTrack: Track = {
id: crypto.randomUUID(),
name: action.property,
property: action.property,
keyframes: [],
color: colors[state.tracks.length % colors.length],
};
return { ...state, tracks: [...state.tracks, newTrack] };
}
case "REMOVE_TRACK":
return {
...state,
tracks: state.tracks.filter((t) => t.id !== action.id),
};
default:
return state;
}
}
export function useTimeline(initialDuration = 1000) {
const [state, dispatch] = useReducer(timelineReducer, {
tracks: [],
duration: initialDuration,
currentTime: 0,
playing: false,
selectedKeyframe: null,
});
const animationRef = useRef<number>();
const startRef = useRef<number>();
useEffect(() => {
if (!state.playing) {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
return;
}
startRef.current = performance.now() - (state.currentTime / 100) * state.duration;
function tick(now: number) {
const elapsed = now - startRef.current!;
const progress = (elapsed / state.duration) * 100;
if (progress >= 100) {
dispatch({ type: "SET_TIME", time: 0 });
dispatch({ type: "PAUSE" });
return;
}
dispatch({ type: "SET_TIME", time: progress });
animationRef.current = requestAnimationFrame(tick);
}
animationRef.current = requestAnimationFrame(tick);
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
};
}, [state.playing, state.duration]);
return { state, dispatch };
}
Timeline Component
"use client";
import type { Track } from "./types";
interface TimelineProps {
tracks: Track[];
currentTime: number;
selectedKeyframe: string | null;
onTimeChange: (time: number) => void;
onKeyframeClick: (id: string) => void;
onKeyframeMove: (id: string, time: number) => void;
onAddKeyframe: (trackId: string, time: number) => void;
}
export function Timeline({
tracks,
currentTime,
selectedKeyframe,
onTimeChange,
onKeyframeClick,
onKeyframeMove,
onAddKeyframe,
}: TimelineProps) {
function handleTrackClick(trackId: string, e: React.MouseEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const time = ((e.clientX - rect.left) / rect.width) * 100;
onAddKeyframe(trackId, Math.round(time));
}
function handleScrub(e: React.MouseEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const time = ((e.clientX - rect.left) / rect.width) * 100;
onTimeChange(Math.round(time));
}
return (
<div className="border rounded-lg overflow-hidden">
{/* Time ruler */}
<div
className="relative h-6 bg-muted cursor-pointer"
onClick={handleScrub}
>
{[0, 25, 50, 75, 100].map((mark) => (
<span
key={mark}
className="absolute text-[10px] text-muted-foreground top-0"
style={{ left: `${mark}%`, transform: "translateX(-50%)" }}
>
{mark}%
</span>
))}
{/* Playhead */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10"
style={{ left: `${currentTime}%` }}
>
<div className="w-3 h-3 bg-red-500 rounded-full -translate-x-1/2 -translate-y-1" />
</div>
</div>
{/* Tracks */}
{tracks.map((track) => (
<div key={track.id} className="flex border-t">
<div className="w-32 px-2 py-2 text-xs font-medium bg-muted/50 border-r flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: track.color }} />
{track.name}
</div>
<div
className="flex-1 relative h-10 cursor-crosshair"
onClick={(e) => handleTrackClick(track.id, e)}
>
{/* Keyframes */}
{track.keyframes.map((kf) => (
<div
key={kf.id}
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-sm rotate-45 cursor-grab ${
selectedKeyframe === kf.id ? "ring-2 ring-primary scale-125" : ""
}`}
style={{
left: `${kf.time}%`,
backgroundColor: track.color,
transform: `translateX(-50%) translateY(-50%) rotate(45deg) ${
selectedKeyframe === kf.id ? "scale(1.25)" : ""
}`,
}}
onClick={(e) => {
e.stopPropagation();
onKeyframeClick(kf.id);
}}
draggable
onDrag={(e) => {
if (e.clientX === 0) return;
const parent = e.currentTarget.parentElement!;
const rect = parent.getBoundingClientRect();
const time = ((e.clientX - rect.left) / rect.width) * 100;
onKeyframeMove(kf.id, Math.round(time));
}}
/>
))}
{/* Playhead line */}
<div
className="absolute top-0 bottom-0 w-px bg-red-500/50"
style={{ left: `${currentTime}%` }}
/>
</div>
</div>
))}
</div>
);
}
Export to CSS
export function exportToCSS(tracks: Track[], duration: number): string {
const keyframeBlocks: string[] = [];
// Group keyframes by time
const timeMap = new Map<number, Record<string, string | number>>();
for (const track of tracks) {
for (const kf of track.keyframes) {
const existing = timeMap.get(kf.time) ?? {};
Object.assign(existing, kf.properties);
timeMap.set(kf.time, existing);
}
}
// Build @keyframes
const sortedTimes = Array.from(timeMap.keys()).sort((a, b) => a - b);
const frames = sortedTimes.map((time) => {
const props = timeMap.get(time)!;
const cssProps = Object.entries(props)
.map(([key, value]) => ` ${key}: ${value};`)
.join("\n");
return ` ${time}% {\n${cssProps}\n }`;
});
return `@keyframes custom-animation {\n${frames.join("\n")}\n}\n\n.animated {\n animation: custom-animation ${duration}ms ease forwards;\n}`;
}
Need Motion Design Tools?
We build animation tools and interactive experiences. Contact us to bring your ideas to life.