Skip to main content
Back to Blog
Tutorials
3 min read
January 21, 2025

How to Build an Animation Timeline Editor in React

Create a visual timeline editor for animations with keyframe placement, easing curves, playback controls, and CSS export.

Ryel Banfield

Founder & Lead Developer

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.

animationtimelinekeyframesReacteditortutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles