R RenderComp
remotion character-animation svg animation tutorial

Character Animation in Remotion: Rigging and Animating 2D Characters in React

Character Animation in Remotion: Rigging and Animating 2D Characters in React

Animated characters — mascots, explainer presenters, reaction figures in social clips — are usually treated as the domain of After Effects and dedicated rigging plugins. But a 2D character is, structurally, just a tree of shapes with pivot points. That makes it a natural fit for Remotion: the rig is a React component, each joint is a grouped transform, and every motion is a deterministic function of useCurrentFrame().

This guide builds that workflow in Remotion 4.x: choosing an approach, constructing an SVG rig, driving joints with interpolate() and spring(), loopable walk and idle cycles, basic lip sync, and packaging the character as a props-driven, reusable component.


Three Approaches: Sprite Sheets, SVG Rigs, and Lottie Characters

There are three practical ways to get a character moving in Remotion, each trading art flexibility against programmatic control.

Sprite sheets are pre-drawn animation frames packed into one image; you step through cells based on the current frame. Any art style works and rendering is cheap, but every animation must be drawn in advance — no changing colors, poses, or timing at render time.

SVG rigs draw the character as vector shapes grouped by body part, with rotation transforms at joint pivots. Everything is code: limb angles, colors, expressions, and timing are all variables you can drive from props and data. This is the most “Remotion-native” approach and the focus of this guide.

Lottie characters are After Effects animations exported via Bodymovin and played with the @remotion/lottie package, frame-synchronized to your composition. You get designer-grade motion quality, but the animation is fixed at export time — you control playback, not the skeleton. For when baked animations beat programmatic ones, see the Remotion vs Lottie deep dive.

Here is the sprite sheet approach in full, since it is the shortest to implement:

import { staticFile, useCurrentFrame, useVideoConfig } from 'remotion';

const FRAME_W = 256;
const FRAME_H = 256;
const SHEET_FRAMES = 8; // cells drawn in the sheet
const PLAYBACK_FPS = 12; // classic 2D animation runs on 12fps timing

export const SpriteWalk: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Which sprite-sheet cell to show at the current video frame
  const cell = Math.floor((frame / fps) * PLAYBACK_FPS) % SHEET_FRAMES;

  return (
    <div
      style={{
        width: FRAME_W,
        height: FRAME_H,
        backgroundImage: `url(${staticFile('walk-sheet.png')})`,
        backgroundPosition: `${-cell * FRAME_W}px 0px`,
        backgroundRepeat: 'no-repeat',
      }}
    />
  );
};

The sheet plays at 12fps while the video renders at 30fps — decoupling the two gives sprite animation its hand-drawn cadence. Because the cell index comes from useCurrentFrame(), the result is deterministic and scrubs correctly in Studio.


Building a Simple SVG Character Rig with Grouped Transforms

An SVG rig is a hierarchy: the torso carries the arms and head, the hips carry the legs. Each part that rotates independently gets its own <g> element, rotated around an explicit pivot using the SVG transform attribute form rotate(angle cx cy) — more reliable across renderers than CSS transform-origin on SVG, and it makes pivots explicit in the code.

The rig itself is a pure, stateless component. It knows nothing about frames or time — it just accepts a pose:

export type CharacterPose = {
  headTilt: number; // degrees, pivots at the neck
  leftArm: number;  // degrees, pivots at the shoulder
  rightArm: number;
  leftLeg: number;  // degrees, pivots at the hip
  rightLeg: number;
  bodyY: number;    // vertical bob in px
};

export const Character: React.FC<{
  pose: CharacterPose;
  shirtColor?: string;
  skinColor?: string;
}> = ({ pose, shirtColor = '#3b82f6', skinColor = '#fcd9b8' }) => {
  return (
    <svg viewBox="0 0 200 320" style={{ width: '100%', height: '100%' }}>
      <g transform={`translate(0, ${pose.bodyY})`}>
        {/* Legs — pivot at the hips */}
        <g transform={`rotate(${pose.leftLeg} 88 205)`}>
          <rect x="80" y="200" width="16" height="90" rx="8" fill="#1e293b" />
        </g>
        <g transform={`rotate(${pose.rightLeg} 112 205)`}>
          <rect x="104" y="200" width="16" height="90" rx="8" fill="#1e293b" />
        </g>

        {/* Torso */}
        <rect x="68" y="115" width="64" height="95" rx="24" fill={shirtColor} />

        {/* Arms — pivot at the shoulders */}
        <g transform={`rotate(${pose.leftArm} 74 130)`}>
          <rect x="66" y="122" width="14" height="75" rx="7" fill={shirtColor} />
        </g>
        <g transform={`rotate(${pose.rightArm} 126 130)`}>
          <rect x="120" y="122" width="14" height="75" rx="7" fill={shirtColor} />
        </g>

        {/* Head — pivot at the neck */}
        <g transform={`rotate(${pose.headTilt} 100 118)`}>
          <circle cx="100" cy="72" r="44" fill={skinColor} />
          <circle cx="84" cy="64" r="5" fill="#1e293b" />
          <circle cx="116" cy="64" r="5" fill="#1e293b" />
          <path
            d="M 86 90 Q 100 100 114 90"
            stroke="#1e293b"
            strokeWidth="4"
            fill="none"
            strokeLinecap="round"
          />
        </g>
      </g>
    </svg>
  );
};

Two structural decisions matter here. First, draw order is z-order in SVG — legs are drawn before the torso so they sit behind it, arms after so they pass in front. Second, the eyes and mouth live inside the head group, so a head tilt carries the whole face. Getting the hierarchy right is 80% of rigging; the animation code is comparatively simple.

If you exported your character from Illustrator or Figma, the same principle applies: group the paths by body part and add pivot rotations at the group level. The SVG animation guide covers path-level techniques like stroke drawing and morphing that combine well with rigs.


Joint-Based Motion: interpolate and spring for Limbs and Head

With the rig accepting angles as data, animation is just computing those angles per frame: interpolate() for controlled, eased motion, spring() when a movement should feel physical — an arm snapping up, a head turning with slight overshoot. Here is a character raising an arm with spring physics and waving:

import {
  AbsoluteFill,
  Easing,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { Character } from './Character';

export const WavingCharacter: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // The arm raises with spring physics...
  const raise = spring({
    frame,
    fps,
    config: { mass: 0.8, stiffness: 120, damping: 12 },
  });

  // ...and oscillates around the raised position, two waves per second
  const wave = Math.sin((frame / fps) * Math.PI * 4) * 18;
  const rightArm = interpolate(raise, [0, 1], [0, -150]) + wave * raise;

  // A gentle head tilt toward the raised arm, eased with a bezier curve
  const headTilt = interpolate(frame, [0, 20], [0, -6], {
    extrapolateRight: 'clamp',
    easing: Easing.bezier(0.33, 1, 0.68, 1),
  });

  return (
    <AbsoluteFill style={{ alignItems: 'center', justifyContent: 'center' }}>
      <div style={{ width: 360, height: 576 }}>
        <Character
          pose={{ headTilt, leftArm: 4, rightArm, leftLeg: 0, rightLeg: 0, bodyY: 0 }}
        />
      </div>
    </AbsoluteFill>
  );
};

The detail worth stealing is wave * raise: multiplying the oscillation by the spring’s progress fades the wave in as the arm comes up, instead of the hand wobbling at the character’s side. Layering a periodic function on top of a spring is the core trick behind most convincing character motion.

Spring configuration has an outsized effect on personality — low damping reads as bouncy and cartoonish, high damping as calm and deliberate. The spring animation guide goes deep on tuning mass, stiffness, and damping for different feels.


Walk Cycles, Idle Loops, and Reaction Beats

Walk cycles

A walk cycle is periodic motion, and periodic motion in Remotion is Math.sin() over time. The key relationships: opposite legs are half a cycle out of phase, arms swing opposite to their same-side leg, and the body bobs once per footfall.

export const WalkingCharacter: React.FC<{ stepsPerSecond?: number }> = ({
  stepsPerSecond = 2,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // One step per π of phase; a full cycle (left + right step) per 2π
  const phase = (frame / fps) * stepsPerSecond * Math.PI;

  const leftLeg = Math.sin(phase) * 26;
  const rightLeg = Math.sin(phase + Math.PI) * 26;
  const leftArm = Math.sin(phase + Math.PI) * 20; // opposite to same-side leg
  const rightArm = Math.sin(phase) * 20;
  const bodyY = Math.abs(Math.sin(phase)) * -4; // bob on each footfall
  const headTilt = Math.sin(phase) * 2;

  return <Character pose={{ headTilt, leftArm, rightArm, leftLeg, rightLeg, bodyY }} />;
};

Because everything derives from one phase value, the cycle loops seamlessly at any duration, and changing stepsPerSecond retimes the whole body coherently. Add a translateX on the parent driven by interpolate(frame, ...) and the character walks across the screen.

Idle loops

Two cheap ingredients stop a character from looking like a sticker when nothing is happening: slow breathing and periodic blinks.

// Slow breathing: subtle vertical bob, ~0.4 Hz
const breathe = Math.sin((frame / fps) * Math.PI * 0.8) * 1.5;

// Blink: eyes close for 4 frames every 3 seconds
const blink = frame % (fps * 3) < 4 ? 0.1 : 1;

// In the head group, render eyes as ellipses so ry can collapse:
// <ellipse cx="84" cy="64" rx="5" ry={5 * blink} fill="#1e293b" />

Reaction beats

Reactions — a surprised hop, a happy bounce — are one-shot motions placed at specific moments. The cleanest structure is one <Sequence> per beat, so each pose component’s useCurrentFrame() restarts from zero and its springs fire fresh:

<AbsoluteFill>
  <Sequence durationInFrames={90}>
    <IdleCharacter />
  </Sequence>
  <Sequence from={90} durationInFrames={60}>
    <SurprisedCharacter />
  </Sequence>
  <Sequence from={150}>
    <WavingCharacter />
  </Sequence>
</AbsoluteFill>

For a beat inside an existing component, use the spring’s delay option instead — spring({ frame, fps, delay: 60, config }) stays at rest until frame 60, then fires. Map its output through interpolate(pop, [0, 0.5, 1], [0, -50, 0]) for an up-and-back hop from a single spring.


Basic Lip Sync and Talking-Head Timing

Full lip sync is a deep discipline, but a convincing basic version needs only three mouth shapes — closed, mid, open — and a timeline of cues saying which shape is active when. The open-source tool Rhubarb Lip Sync can generate these cues automatically from a voiceover audio file; its output maps cleanly onto this structure.

import { staticFile, useCurrentFrame, useVideoConfig } from 'remotion';
import { Audio } from '@remotion/media';

type MouthCue = {
  start: number; // seconds
  end: number;
  shape: 'closed' | 'mid' | 'open';
};

const cues: MouthCue[] = [
  { start: 0.0, end: 0.22, shape: 'mid' },
  { start: 0.22, end: 0.4, shape: 'open' },
  { start: 0.4, end: 0.55, shape: 'closed' },
  // ...generated from your audio, not hand-authored
];

const Mouth: React.FC<{ shape: MouthCue['shape'] }> = ({ shape }) => {
  if (shape === 'open') {
    return <ellipse cx="100" cy="92" rx="10" ry="8" fill="#7f1d1d" />;
  }
  if (shape === 'mid') {
    return <ellipse cx="100" cy="92" rx="9" ry="4" fill="#7f1d1d" />;
  }
  return (
    <path d="M 88 92 L 112 92" stroke="#1e293b" strokeWidth="4" strokeLinecap="round" />
  );
};

export const TalkingHead: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const t = frame / fps;

  const active = cues.find((c) => t >= c.start && t < c.end);

  return (
    <>
      <Audio src={staticFile('voiceover.mp3')} />
      <svg viewBox="0 0 200 200" style={{ width: 600, height: 600, margin: 'auto' }}>
        <circle cx="100" cy="72" r="44" fill="#fcd9b8" />
        <circle cx="84" cy="64" r="5" fill="#1e293b" />
        <circle cx="116" cy="64" r="5" fill="#1e293b" />
        <Mouth shape={active?.shape ?? 'closed'} />
      </svg>
    </>
  );
};

Because the audio playback and the mouth-shape lookup derive from the same frame clock, they can never drift out of sync — scrub to any frame in Studio and the mouth matches the audio at that instant. For longer dialogue, layer subtle head motion on top of the cues; a perfectly still head with a moving mouth quickly reads as uncanny.


Making Characters Props-Driven for Reuse Across Videos

The real payoff of characters-as-components is reuse. If every visual and behavioral decision is a prop, the same rig serves dozens of videos with different colors, animations, and pacing — driven from defaultProps in Studio or inputProps in an automated render pipeline.

export type MascotProps = {
  shirtColor: string;
  skinColor: string;
  animation: 'idle' | 'walk' | 'wave';
  stepsPerSecond: number;
};

// In src/Root.tsx
<Composition
  id="Mascot"
  component={Mascot}
  durationInFrames={240}
  fps={30}
  width={1080}
  height={1080}
  defaultProps={{
    shirtColor: '#3b82f6',
    skinColor: '#fcd9b8',
    animation: 'wave',
    stepsPerSecond: 2,
  }}
/>

Inside Mascot, the animation prop selects which pose-computation branch runs, and the color props flow straight into the SVG fills. Guidelines that keep character components genuinely reusable:

  • Separate the rig from the motion. The Character component renders a pose; motion components compute poses. One rig then serves every animation.
  • Define props with type, not interface, and consider a Zod schema so props are editable and validated in Remotion Studio’s props panel.
  • Express timing in seconds, not frames (stepsPerSecond, cue times), converting via fps inside the component, so the character works at 24, 30, and 60fps alike.
  • Give every prop a sensible default so the composition renders something presentable with zero configuration.

Performance: Keeping Multi-Character Scenes Fast

Character rigs are light — a full SVG character is a few dozen DOM nodes, and transforms are cheap to animate. Scenes with five or ten characters render fine. When scaling further:

  • Unmount off-screen characters. Wrap each character’s active period in a <Sequence durationInFrames={...}>. Sequences unmount children outside their window, so a character that appears for 3 seconds costs nothing elsewhere.
  • Memoize static geometry. Wrap mount-time computations (patterned clothing, generated hair strands) in useMemo so they do not recompute on every frame.
  • Keep filters off individual limbs. A drop-shadow or blur per body part forces expensive rasterization each frame. Apply one filter to the whole character group, or fake shadows with an ellipse under the feet.
  • Prefer sprite sheets for background crowds. Ten hero rigs plus forty sprite-sheet background walkers is a better budget than fifty live rigs.
  • Preview and render performance differ. A laggy Studio preview does not mean a slow or broken final render — rendering is deterministic and captures every frame regardless. Judge output by rendering, and preview at half resolution on heavy scenes.

FAQ

Q: Can I use CSS animations or transitions for character motion?

No. CSS animations run on wall-clock time, not Remotion’s frame clock, so frames are captured out of sync with the intended motion. Every animated value must derive from useCurrentFrame().

Q: Why the SVG transform attribute instead of CSS transform with transform-origin?

CSS transform-origin on SVG elements depends on transform-box behavior and has historically been inconsistent. The attribute form rotate(angle cx cy) bakes the pivot into the transform itself, in viewBox coordinates, and behaves identically everywhere — including Remotion’s Chromium renderer.


Starting Faster with a Character Motion Starter Pack

Everything above is buildable from scratch in a day or two, but the fiddly parts — pivot placement, spring tuning, phase math that loops cleanly, mouth shapes that read at small sizes — benefit most from a tested starting point.

The RenderComp library includes character motion templates with pre-built SVG rigs, ready-made walk, idle, wave, and talking cycles, and typed props for colors, timing, and animation selection. Each ships as editable TypeScript source, so you start from a working character and restyle it into your own mascot instead of debugging shoulder pivots on a blank canvas.

Browse character and motion templates at rendercomp.com

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →