Communitygithub.com

mvpdsigner/faceless-motion-reel

Build a faceless motion-graphics reel (kinetic typography, white canvas, one accent color, animated icons, pills, counters, whip transitions) as code with Remotion and render it to MP4. Use for any reel, short, TikTok, Instagram or LinkedIn video, faceless video, motion graphics, kinetic text or animated explainer request, or when a script, tip or quote should become a short vertical video.

What is faceless-motion-reel?

faceless-motion-reel is a Cursor agent skill that build a faceless motion-graphics reel (kinetic typography, white canvas, one accent color, animated icons, pills, counters, whip transitions) as code with Remotion and render it to MP4. Use for any reel, short, TikTok, Instagram or LinkedIn video, faceless video, motion graphics, kinetic text or animated explainer request, or when a script, tip or quote should become a short vertical video.

Works with~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/mvpdsigner/claude-skills/tree/HEAD/skills/faceless-motion-reel

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

Faceless motion reel

Turn a short script into a 15 to 30 second vertical video in the "minimal visuals, maximum impact" style: a clean white canvas, black type, one accent color, and one small animated object per idea. No footage, no face, no voiceover needed. The video is written as React code with Remotion, so every frame is exact and easy to revise.

The look (what makes this style work)

These rules came from studying a reference reel frame by frame. They matter more than any single effect, so keep them even when you invent new scenes.

  • One idea per scene. Each scene shows one short line (2 to 6 words) plus one object that carries the meaning. A viewer should get it with the sound off.
  • Three colors only. White background, near-black ink, one accent. Grey is used only for text that has "not landed yet". A second accent makes it look like a template.
  • Two type voices. A tight sans (Inter, weight 500, tracking around -0.04em) for most words, and an italic serif in the accent color for the phrase that matters. Use the serif once per scene at most.
  • Text arrives, it does not just appear. New words come in grey and slightly blurred, then settle to black. The eye follows the newest word.
  • Nothing ever sits still. Every scene keeps a slow zoom, and background rings keep drifting. A frozen frame reads as a slideshow.
  • Transitions are camera moves. Scenes push up or sideways with motion blur, or dip to white. No wipes, spins or zoom bursts.
  • Small type, lots of air. Headlines sit around 84 to 100px on a 1080 wide frame. The empty space is the style.
  • The object is the idea. A countdown ring for time, labelled badges for a list of problems, stacking pills for a list of traits, a loop drawn around the payoff line. When the script has a strong image (a clock, a chart, a cursor), draw that object in SVG instead of reusing a kit piece.

Workflow

1. Get the brief

You need: the message or script, the accent color, the logo or name for the last scene, and whether there is a music file. If the user gave a topic but no script, write the script yourself and show it in the beat sheet. Do not stall on questions. Sensible defaults: about 20 seconds, 1080x1920, 30fps, accent #1FB81A, no audio.

2. Write the beat sheet first

Before any code, write the plan as a small table and show it to the user in the same message where you start building. This is where the video is won or lost, because weak beats cannot be fixed with animation.

#Line on screenAccent phraseObjectIn / outFrames

Guidelines:

  • Beat 1 is the hook: 2 to 5 words, stated as a tension or a bold claim.
  • 6 to 9 beats for 15 to 30 seconds. Most beats run 60 to 110 frames (2 to 3.5 seconds). Give list beats (badges, pills) more time: about 20 frames per item plus 30 to read.
  • Build toward one payoff line near the end and give it the loop (Circled).
  • The last beat is the logo or handle, 45 to 60 frames.
  • Vary the object from beat to beat. Two text-only beats in a row is fine. Two badge beats in a row is not.
  • Vary transitions: mostly up pushes, one sideways push, a fade before the payoff.

3. Set up the project

Run this once in an empty folder. Fonts are copied into the project so rendering never depends on the network.

npm init -y
npm i remotion @remotion/cli @remotion/fonts react react-dom lucide-react @fontsource/inter @fontsource/instrument-serif
npm i -D typescript @types/react
mkdir -p src public/fonts out
cp node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2 node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2 node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2 public/fonts/

tsconfig.json:

{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"noEmit":true,"esModuleInterop":true},"include":["src"]}

src/index.ts:

import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);

src/Root.tsx:

import React from 'react';
import {Composition} from 'remotion';
import {totalFrames} from './kit';
import {Video, beats} from './Video';

export const Root: React.FC = () => (
  <Composition id="Reel" component={Video} width={1080} height={1920} fps={30} durationInFrames={totalFrames(beats)} />
);

4. Add the motion kit

Create src/kit.tsx with exactly this code. It has been rendered and checked, so copy it as is and change only the theme line. Build new pieces next to it in the same spirit.

import React from 'react';
import {
  AbsoluteFill, Easing, Sequence, interpolate, interpolateColors, random, spring,
  staticFile, useCurrentFrame, useVideoConfig,
} from 'remotion';
import {loadFont} from '@remotion/fonts';

// Fonts are local files (copied from @fontsource at setup), so renders never need the network.
const font = (family: string, file: string, weight: string, style = 'normal') =>
  loadFont({family, url: staticFile(`fonts/${file}.woff2`), weight, style});
font('Inter', 'inter-latin-400-normal', '400');
font('Inter', 'inter-latin-500-normal', '500');
font('Instrument Serif', 'instrument-serif-latin-400-italic', '400', 'italic');
const sans = 'Inter, sans-serif';
const serif = '"Instrument Serif", serif';

// ---- Theme: one background, one ink, one grey, ONE accent. Swap accent per brand (6-digit hex). ----
export const T = {bg: '#FFFFFF', ink: '#0B0B0C', muted: '#B4B6BB', accent: '#1FB81A', sans, serif};

const CL = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
const c01 = (n: number) => Math.min(1, Math.max(0, n));
const whip = Easing.bezier(0.76, 0, 0.18, 1); // slow, fast, slow: the "camera whip"

/** 0 to 1 spring that starts at `delay`. Smooth by default; pass a config for bounce. */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  return spring({frame: frame - delay, fps, config});
};

// ---- Timeline: scenes overlap by OVERLAP frames so the exit and the enter happen together ----
export const OVERLAP = 12;
export type Beat = {dur: number; el: React.ReactNode};
export const beatStarts = (beats: Beat[], overlap = OVERLAP) =>
  beats.map((_, i) => beats.slice(0, i).reduce((s, x) => s + x.dur - overlap, 0));
export const totalFrames = (beats: Beat[], overlap = OVERLAP) =>
  beats.reduce((s, b) => s + b.dur, 0) - overlap * (beats.length - 1);

export const Timeline: React.FC<{beats: Beat[]; overlap?: number}> = ({beats, overlap = OVERLAP}) => {
  const starts = beatStarts(beats, overlap);
  return (
    <AbsoluteFill style={{background: T.bg}}>
      {beats.map((b, i) => (
        <Sequence key={i} from={starts[i]} durationInFrames={b.dur}>
          {b.el}
        </Sequence>
      ))}
    </AbsoluteFill>
  );
};

// ---- Scene: wraps one idea. Handles the push/fade in and out, motion blur, slow zoom. ----
type Dir = 'up' | 'down' | 'left' | 'right' | 'fade';
const VEC: Record<Dir, [number, number]> = {up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], fade: [0, 0]};

/** `enter="up"` = content travels up into place. For a push, give scene A exit="up" and scene B enter="up". */
export const Scene: React.FC<{
  enter?: Dir; exit?: Dir; t?: number; zoom?: number; children: React.ReactNode;
}> = ({enter = 'fade', exit = 'fade', t = OVERLAP, zoom = 0.05, children}) => {
  const frame = useCurrentFrame();
  const {width, height, durationInFrames: dur} = useVideoConfig(); // = this Sequence's length
  const id = React.useId().replace(/:/g, '');
  const pos = (f: number) => {
    const a = 1 - whip(c01(f / t));
    const b = whip(c01((f - (dur - t)) / t));
    const [ex, ey] = VEC[enter];
    const [lx, ly] = VEC[exit];
    return [(-ex * a + lx * b) * width, (-ey * a + ly * b) * height];
  };
  const [x, y] = pos(frame);
  const [px, py] = pos(frame - 1);
  const bx = Math.abs(x - px) * 0.35; // blur follows speed, like a real shutter
  const by = Math.abs(y - py) * 0.35;
  const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // old scene clears first
  const fOut = exit === 'fade' ? interpolate(frame, [dur - t, dur - t * 0.5], [1, 0], CL) : 1;
  const soft = (1 - fIn * fOut) * 14;
  const filter = bx + by > 0.4 ? `url(#${id})` : soft > 0.2 ? `blur(${soft}px)` : undefined;
  return (
    <>
      <svg width="0" height="0" style={{position: 'absolute'}}>
        <filter id={id} x="-40%" y="-40%" width="180%" height="180%">
          <feGaussianBlur stdDeviation={`${bx} ${by}`} />
        </filter>
      </svg>
      <AbsoluteFill
        style={{
          transform: `translate(${x}px, ${y}px) scale(${1 + (zoom * frame) / dur})`,
          opacity: fIn * fOut, filter,
          fontFamily: T.sans, color: T.ink, alignItems: 'center', justifyContent: 'center',
        }}
      >
        {children}
      </AbsoluteFill>
    </>
  );
};

// ---- Text ----
/** Reveals text word by word (or letter by letter). New text lands grey, then settles to ink.
 *  Wrap the key phrase in *stars* to make it the accent. Use \n for a line break. */
export const Words: React.FC<{
  text: string; delay?: number; stagger?: number; by?: 'word' | 'char';
  size?: number; accent?: 'serif' | 'sans'; style?: React.CSSProperties;
}> = ({text, delay = 0, stagger, by = 'word', size = 92, accent = 'serif', style}) => {
  const frame = useCurrentFrame();
  const step = stagger ?? (by === 'word' ? 5 : 1.2);
  let i = 0;
  const unit = (s: string, hot: boolean, key: string) => {
    const local = frame - delay - i++ * step;
    const p = interpolate(local, [0, 6], [0, 1], CL);
    const final = hot ? T.accent : T.ink;
    return (
      <span
        key={key}
        style={{
          display: 'inline-block', whiteSpace: 'pre', opacity: p,
          color: interpolateColors(c01((local - 3) / 9), [0, 1], [T.muted, final]),
          transform: by === 'word' ? `translateY(${(1 - p) * 0.2}em)` : undefined,
          filter: p < 1 ? `blur(${(1 - p) * 7}px)` : undefined,
        }}
      >
        {s}
      </span>
    );
  };
  return (
    <div style={{fontSize: size, fontWeight: 500, letterSpacing: '-0.04em', lineHeight: 1.08, textAlign: 'center', ...style}}>
      {text.split('\n').map((line, li) => (
        <div key={li}>
          {line.split('*').map((seg, si) => {
            const hot = si % 2 === 1;
            const hotStyle: React.CSSProperties = hot && accent === 'serif'
              ? {fontFamily: T.serif, fontStyle: 'italic', fontWeight: 400, fontSize: '1.14em', letterSpacing: '-0.02em'}
              : {};
            return (
              <span key={si} style={hotStyle}>
                {seg.split(/(\s+)/).filter(Boolean).map((w, wi) =>
                  /^\s+$/.test(w) ? <span key={wi} style={{whiteSpace: 'pre'}}>{w}</span>
                  : by === 'word' ? unit(w, hot, `${wi}`)
                  : <span key={wi} style={{display: 'inline-block', whiteSpace: 'nowrap'}}>{[...w].map((ch, ci) => unit(ch, hot, `${wi}-${ci}`))}</span>
                )}
              </span>
            );
          })}
        </div>
      ))}
    </div>
  );
};

/** The opening hit: starts huge and blurred, snaps to size in about 8 frames. */
export const PunchIn: React.FC<{delay?: number; from?: number; children: React.ReactNode}> = ({delay = 0, from = 3, children}) => {
  const s = useEnter(delay, {damping: 200, stiffness: 220});
  return (
    <div style={{transform: `scale(${interpolate(s, [0, 1], [from, 1])})`, opacity: c01(s * 5), filter: s < 0.98 ? `blur(${(1 - s) * 34}px)` : undefined}}>
      {children}
    </div>
  );
};

/** Small supporting line under the headline. */
export const Caption: React.FC<{children: React.ReactNode; delay?: number; width?: number}> = ({children, delay = 0, width = 620}) => {
  const s = useEnter(delay);
  return (
    <div style={{fontSize: 31, lineHeight: 1.28, letterSpacing: '-0.02em', color: '#2B2C2F', textAlign: 'center', width, marginTop: 44, opacity: s, filter: s < 0.98 ? `blur(${(1 - s) * 6}px)` : undefined}}>
      {children}
    </div>
  );
};

// ---- Atmosphere ----
/** Soft accent light behind the key word. Place it with x / y as CSS values. */
export const Glow: React.FC<{x?: string; y?: string; size?: number; delay?: number}> = ({x = '50%', y = '50%', size = 900, delay = 0}) => {
  const s = useEnter(delay);
  const frame = useCurrentFrame();
  return (
    <div style={{position: 'absolute', left: x, top: y, width: size, height: size, borderRadius: '50%', opacity: s,
      transform: `translate(-50%, -50%) scale(${0.6 + 0.4 * s + frame * 0.0012})`,
      background: `radial-gradient(closest-side, ${T.accent}3D, ${T.accent}00)`}} />
  );
};

/** Drifting circles near the edges so the frame never sits still. thin = outlines, soft = fat blurred rings. */
export const Rings: React.FC<{seed?: string; variant?: 'thin' | 'soft'; count?: number}> = ({seed = 'a', variant = 'thin', count = 3}) => {
  const frame = useCurrentFrame();
  const {width, height} = useVideoConfig();
  return (
    <AbsoluteFill>
      {Array.from({length: count}).map((_, i) => {
        const r = (k: string) => random(`${seed}-${i}-${k}`);
        const d = 300 + r('d') * 420;
        const cx = (i % 2 ? 0.86 + r('x') * 0.2 : -0.06 + r('x') * 0.2) * width;
        const cy = (r('y') * 1.1 - 0.05) * height;
        const ang = r('a') * Math.PI * 2;
        const s = spring({frame: frame - i * 4, fps: 30, config: {damping: 200}});
        return (
          <div key={i} style={{position: 'absolute', width: d, height: d, borderRadius: '50%', opacity: s,
            left: cx - d / 2 + Math.cos(ang) * frame * 0.9, top: cy - d / 2 + Math.sin(ang) * frame * 0.9,
            border: variant === 'thin' ? `2px solid ${T.accent}99` : `${d * 0.2}px solid ${T.accent}1F`,
            filter: variant === 'soft' ? 'blur(14px)' : undefined}} />
        );
      })}
    </AbsoluteFill>
  );
};

// ---- Props (the little objects that carry the idea) ----
/** Tag that slides in from one side. Alternate filled and outline. */
export const Pill: React.FC<{children: React.ReactNode; filled?: boolean; delay?: number; from?: 'left' | 'right'; size?: number}> = ({children, filled, delay = 0, from = 'left', size = 44}) => {
  const s = useEnter(delay, {damping: 22, stiffness: 150});
  return (
    <div style={{display: 'inline-block', fontSize: size, letterSpacing: '-0.03em', padding: '0.42em 0.95em', borderRadius: 999, whiteSpace: 'nowrap',
      background: filled ? T.accent : T.bg, color: filled ? '#fff' : T.ink, border: `2.5px solid ${filled ? T.accent : T.ink}`,
      opacity: c01(s * 2.5), transform: `translateX(${(1 - s) * (from === 'left' ? -700 : 700)}px)`}}>
      {children}
    </div>
  );
};

/** Progress ring with a number that counts. */
export const Counter: React.FC<{from?: number; to: number; delay?: number; dur?: number; size?: number; label?: string}> = ({from = 0, to, delay = 0, dur = 40, size = 400, label}) => {
  const frame = useCurrentFrame();
  const s = useEnter(delay);
  const p = interpolate(frame - delay, [0, dur], [0, 1], {...CL, easing: Easing.out(Easing.cubic)});
  const sw = size * 0.085;
  const r = (size - sw) / 2;
  const C = 2 * Math.PI * r;
  return (
    <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', opacity: s, transform: `scale(${0.7 + 0.3 * s})`}}>
      <div style={{position: 'relative', width: size, height: size, borderRadius: '50%', boxShadow: `0 0 ${size * 0.3}px ${T.accent}55`}}>
        <svg width={size} height={size} style={{transform: 'rotate(-90deg)'}}>
          <circle cx={size / 2} cy={size / 2} r={r} fill={T.bg} stroke="#CFD1D4" strokeWidth={sw} />
          <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={T.accent} strokeWidth={sw} strokeLinecap="round" strokeDasharray={C} strokeDashoffset={C * (1 - p)} />
        </svg>
        <div style={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.36, fontWeight: 500, letterSpacing: '-0.05em', fontVariantNumeric: 'tabular-nums'}}>
          {Math.round(from + (to - from) * p)}
        </div>
      </div>
      {label ? <div style={{fontSize: size * 0.13, marginTop: size * 0.07, letterSpacing: '-0.03em'}}>{label}</div> : null}
    </div>
  );
};

/** Draws any SVG path on, like a pen stroke. Use for underlines, arrows, connectors, custom line art.
 *  `width` is in viewBox units, so keep the viewBox close to the pixel size you render at. */
export const Draw: React.FC<{d: string; viewBox: string; delay?: number; dur?: number; stroke?: string; width?: number; style?: React.CSSProperties}> = ({d, viewBox, delay = 0, dur = 16, stroke = T.accent, width = 4, style}) => {
  const frame = useCurrentFrame();
  const p = interpolate(frame - delay, [0, dur], [0, 1], {...CL, easing: Easing.inOut(Easing.cubic)});
  return (
    <svg viewBox={viewBox} style={{overflow: 'visible', ...style}}>
      <path d={d} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - p} fill="none" stroke={stroke} strokeWidth={width}
        strokeLinecap="round" strokeLinejoin="round" opacity={p > 0 ? 1 : 0} />
    </svg>
  );
};

/** Loop that sweeps around the payoff phrase. */
export const Circled: React.FC<{children: React.ReactNode; delay?: number; dur?: number; padX?: number; padY?: number}> = ({children, delay = 0, dur = 18, padX = 60, padY = 34}) => {
  const frame = useCurrentFrame();
  const p = interpolate(frame - delay, [0, dur], [0, 1], {...CL, easing: Easing.inOut(Easing.cubic)});
  const sweep = `conic-gradient(from -100deg, #000 ${p * 360}deg, transparent ${p * 360}deg)`;
  return (
    <span style={{position: 'relative', display: 'inline-block', padding: `${padY}px ${padX}px`}}>
      {children}
      <span style={{position: 'absolute', inset: 0, borderRadius: '50%', border: `5px solid ${T.accent}`, transform: 'rotate(-2.5deg)',
        boxShadow: `0 0 26px ${T.accent}66, inset 0 0 18px ${T.accent}33`, maskImage: sweep, WebkitMaskImage: sweep}} />
    </span>
  );
};

/** Accent hexagon with an icon inside and a labelled callout line. */
export const Badge: React.FC<{icon: React.ReactNode; label: string; delay?: number; size?: number; labelAt?: 'top' | 'bottom'}> = ({icon, label, delay = 0, size = 230, labelAt = 'top'}) => {
  const s = useEnter(delay, {damping: 16, stiffness: 160});
  const t = useEnter(delay + 10);
  const top = labelAt === 'top';
  return (
    <div style={{position: 'relative', width: size, height: size}}>
      <svg viewBox="0 0 100 100" width={size} height={size} style={{transform: `scale(${s}) rotate(${(1 - s) * -40}deg)`}}>
        <polygon points="50,6 88,28 88,72 50,94 12,72 12,28" fill={T.accent} stroke={T.accent} strokeWidth={8} strokeLinejoin="round" />
      </svg>
      <div style={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', transform: `scale(${s})`}}>{icon}</div>
      <Draw delay={delay + 6} dur={12} stroke={T.ink} width={2.5} viewBox="0 0 60 100" d={top ? 'M 30 100 L 30 45 L 5 5' : 'M 30 0 L 30 55 L 55 95'}
        style={{position: 'absolute', left: size / 2 - 30, width: 60, height: 100, ...(top ? {bottom: size - 8} : {top: size - 8})}} />
      <div style={{position: 'absolute', whiteSpace: 'nowrap', fontSize: 30, letterSpacing: '-0.03em', opacity: t, ...(top ? {bottom: size + 100, right: size / 2 - 10} : {top: size + 100, left: size / 2 - 10})}}>{label}</div>
    </div>
  );
};

/** Camera-style corner brackets that lock onto whatever they wrap. */
export const Brackets: React.FC<{children: React.ReactNode; delay?: number; pad?: number; len?: number}> = ({children, delay = 0, pad = 56, len = 72}) => {
  const s = useEnter(delay, {damping: 20, stiffness: 140});
  const corner = (v: 'top' | 'bottom', h: 'left' | 'right'): React.CSSProperties => ({
    position: 'absolute', [v]: 0, [h]: 0, width: len, height: len,
    [`border${v === 'top' ? 'Top' : 'Bottom'}`]: `3px solid ${T.accent}`, [`border${h === 'left' ? 'Left' : 'Right'}`]: `3px solid ${T.accent}`,
  });
  return (
    <div style={{position: 'relative', padding: pad}}>
      <div style={{position: 'absolute', inset: 0, opacity: c01(s), transform: `scale(${1.25 - 0.25 * s})`}}>
        <div style={corner('top', 'left')} /><div style={corner('top', 'right')} />
        <div style={corner('bottom', 'left')} /><div style={corner('bottom', 'right')} />
      </div>
      {children}
    </div>
  );
};

5. Write the video from the beat sheet

src/Video.tsx holds a beats array. Each beat is a length in frames and a <Scene>. All delay values are frames counted from the start of that scene. Here is a full worked example that uses every kit piece. Treat it as a pattern to adapt, and write new copy and new layouts for each video.

import React from 'react';
import {MousePointerClick, Timer, Layers} from 'lucide-react';
import {Badge, Beat, Brackets, Caption, Circled, Counter, Glow, Pill, PunchIn, Rings, Scene, Timeline, Words} from './kit';

const row: React.CSSProperties = {display: 'flex', gap: 18, alignItems: 'center'};

export const beats: Beat[] = [
  {dur: 66, el: (
    <Scene exit="up">
      <Rings seed="s1" />
      <Glow x="62%" delay={20} />
      <PunchIn><Words text="Good design is *quiet.*" stagger={9} accent="sans" size={96} /></PunchIn>
    </Scene>
  )},
  {dur: 84, el: (
    <Scene enter="up" exit="left">
      <Brackets delay={8}>
        <Counter to={5} delay={10} dur={46} label="seconds" />
      </Brackets>
      <div style={{height: 70}} />
      <Words text={'You only get\n*a few of them*'} delay={16} size={84} />
      <Caption delay={34}>That is how long a new visitor gives your page before they decide to stay or leave.</Caption>
    </Scene>
  )},
  {dur: 96, el: (
    <Scene enter="left" exit="up">
      <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginTop: 120}}>
        <div style={{display: 'flex', gap: 30}}>
          <Badge delay={10} label="Unclear buttons" icon={<MousePointerClick size={96} strokeWidth={1.4} />} />
          <Badge delay={30} label="Slow pages" icon={<Timer size={96} strokeWidth={1.4} />} />
        </div>
        <div style={{marginTop: -22}}>
          <Badge delay={50} labelAt="bottom" label="No hierarchy" icon={<Layers size={96} strokeWidth={1.4} />} />
        </div>
      </div>
    </Scene>
  )},
  {dur: 108, el: (
    <Scene enter="up" exit="fade">
      <Rings seed="s4" variant="soft" count={4} />
      <div style={{display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'flex-start', width: 900}}>
        <div style={row}><Pill filled delay={10}>Quiet design is clear.</Pill><Pill delay={22} from="right">Fast</Pill></div>
        <div style={row}><Pill delay={34}>Consistent</Pill><Pill filled delay={46} from="right">One goal per screen</Pill></div>
        <div style={row}><Pill filled delay={58}>An obvious next step</Pill></div>
        <div style={{...row, alignSelf: 'flex-end'}}><Pill delay={70} from="right">Nothing to figure out</Pill></div>
      </div>
    </Scene>
  )},
  {dur: 90, el: (
    <Scene>
      <Glow x="66%" y="54%" size={760} delay={44} />
      <Words by="char" size={66} delay={8} text="Clarity isn't decoration." />
      <Circled delay={50}><Words by="char" size={86} delay={36} text="It's conversion." /></Circled>
    </Scene>
  )},
  {dur: 48, el: (
    <Scene>
      <PunchIn from={1.6}>
        <div style={{fontSize: 40, letterSpacing: '-0.03em', fontWeight: 500}}>your logo here</div>
      </PunchIn>
    </Scene>
  )},
];

export const Video: React.FC = () => <Timeline beats={beats} />;

For a logo image, put the file in public/ and use <Img src={staticFile('logo.png')} style={{width: 220}} /> (import Img and staticFile from remotion).

6. Look at your own frames before you call it done

Code that compiles can still look wrong: text off the edge, objects on top of each other, a scene that is empty for a second. So render stills and open them with the Read tool. This check is what separates a good result from a broken one.

npx tsc
npx remotion still src/index.ts Reel out/check-1.png --frame=50

Which frames to check: for each beat, the last settled frame is start + dur - OVERLAP - 2, where start is the sum of earlier dur values minus OVERLAP for each earlier beat. Also check one frame in the middle of a list beat.

What to look for:

  • Key content stays inside x 80 to 1000 and y 280 to 1500. Instagram covers the top and bottom with its own buttons and caption.
  • Every word is readable and nothing overlaps.
  • The accent phrase is the first thing the eye lands on.
  • The scene has finished animating at least 20 frames before it leaves, so there is time to read.
  • Frame 0 is blank white by design. If the user wants a cover image, render a settled frame as a still for the cover.

Fix what you find and re-render the stills. If ffmpeg is installed, one contact sheet of the whole video is a fast final check:

ffmpeg -y -i out/reel.mp4 -vf "fps=5,scale=216:-1,tile=8x2:padding=4" out/sheet-%02d.png

7. Render and hand over

npx remotion render src/index.ts Reel out/reel.mp4

The first render downloads a small headless browser on its own. For live preview with a scrubber, the user can run npx remotion studio src/index.ts.

Tell the user where the MP4 is, how long it is, and the one or two things they are most likely to want changed (accent color in T, the copy in Video.tsx).

Optional: music and sound

Only add audio when the user gives a file they have the rights to use. Put it in public/ and change the last line of Video.tsx to:

export const Video: React.FC = () => {
  const {durationInFrames: d} = useVideoConfig();
  return (
    <>
      <Timeline beats={beats} />
      <Audio src={staticFile('music.mp3')} volume={(f) => interpolate(f, [0, 10, d - 25, d], [0, 0.8, 0.8, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'})} />
      {beatStarts(beats).slice(1).map((s) => (
        <Sequence key={s} from={s} durationInFrames={30}><Audio src={staticFile('whoosh.mp3')} volume={0.5} /></Sequence>
      ))}
    </>
  );
};

with import {Audio, Sequence, interpolate, staticFile, useVideoConfig} from 'remotion'; and beatStarts added to the kit import. Drop the whoosh block if there is no whoosh file, because a missing file fails the render. When there is music, set beat lengths so the pushes land on the beat: at 30fps one beat is 1800 / BPM frames.

Kit at a glance

PieceUse it forNotes
SceneEvery beatenter / exit: up, down, left, right, fade. Match A's exit to B's enter for a push.
WordsAll headline text*stars* mark the accent phrase. by="char" types letter by letter. accent="sans" keeps the accent in the sans font.
PunchInHook and logoWrap the first line so it slams in from large and blurred.
CaptionOne small support lineKeep under 20 words.
GlowLight behind the accent wordPlace with x and y near that word.
RingsBackground lifethin for calm scenes, soft for busy ones. Change seed per scene.
PillLists of traitsAlternate filled, alternate from, 12 frames apart.
CounterTime, numbers, percentagesto is the end number, label sits under the ring.
BadgeLists of problems or featuresIcons come from lucide-react. 20 frames apart.
BracketsFocus on one objectWrap any element.
CircledThe payoff lineOnce per video.
DrawUnderlines, arrows, custom line artAny SVG path draws on like a pen.

Making your own objects

The kit covers the common beats, but the best videos in this style have at least one object drawn for that script: a clock hand sweeping for "every second", a row of people icons with one in focus for "your audience", review cards floating in for "what clients say". Build these as small components with plain divs and inline SVG, using the same three colors and these habits:

  • Read time from useCurrentFrame() and shape it with spring or interpolate. CSS transitions, CSS keyframes and setTimeout do not work, because Remotion renders each frame on its own and anything that depends on real time comes out wrong or flickers.
  • Enter with useEnter(delay). Use {damping: 200} for smooth and around {damping: 18, stiffness: 150} for a small overshoot on objects. Text never bounces.
  • Stagger related items by 10 to 20 frames so the eye can follow.
  • Keep a little motion after the entrance (a slow rotate, drift or count) so the object stays alive.

Things that go wrong

  • delay is local. Inside a Scene, frame 0 is the start of that scene, not of the video.
  • Words reserve their space. The line is laid out at full width from the start and words fade into place, so a line never jumps while it types.
  • em padding on wrappers. A wrapper span does not inherit the size passed to Words, so use pixel padding (this is why Circled takes padX and padY).
  • Accent must be a 6-digit hex. The kit adds alpha by appending two hex digits.
  • Long lines. Above about 20 characters at size 92, break the line with \n or drop the size. Check a still.
  • Too many blurred things at once make renders slow. soft rings plus a glow plus a push is fine. Do not stack more.
  • Licence. Remotion is free for individuals and very small teams. Larger companies need a paid licence, so point the user to remotion.dev/license if the work is for a bigger client.

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Related Skills