Skip to main content
skill CX Codex

caption-animation — Claude Code skill: animated word-by-word captions

A skill for word-timed captions: each word pops in on its syllable, the active word highlights, and text stays inside the 9:16 safe area.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add iart-ai/tiktok-video-skills --skill caption-animation -a claude-code
Open on GitHub

What caption-animation does

The skill turns a transcript or voiceover into captions with word-level timing. Every word scales in from 60% to 100% with a small overshoot, and the live word gets a single accent color. Words are grouped into pages of 1–4 so the screen never fills with text.

The pipeline runs from transcription to export. Whisper or AssemblyAI produces word timestamps, tokens are normalized to { text, startMs, endMs }, createTikTokStyleCaptions() builds the pages, and the animation comes from spring() in Remotion or a CSS keyframe with animation-delay set to startMs. For a 1080×1920 frame the skill specifies bold type at 56–80px, a 2–6px outline, and a center band at 62–70% height, leaving the bottom 280px for platform UI.

If all you have is sentence-level SRT, you have to re-transcribe. Splitting a line evenly across its duration drifts, and the karaoke effect falls apart. The skill suggests medium.en over large for clean voice audio. Output can be both burned-in captions for social and a sidecar SRT/VTT for accessibility and SEO.

What it helps with

  • build word-by-word karaoke captions for vertical video
  • burn animated captions into an MP4 from a voiceover track
  • restyle auto-generated subtitles with an active-word highlight
  • place captions inside the 9:16 safe area without hitting platform UI
  • emit SRT/VTT from the same tokens for accessibility

Example prompts for the agent

  • add animated captions to this vertical video
  • make karaoke captions from the Whisper transcript and highlight the active word
  • burn the captions into the video and export an SRT too

How to install caption-animation

1Skills CLI (Vercel)

npx skills add iart-ai/tiktok-video-skills --skill caption-animation -a claude-code

2Manually: put the files in place

~/.claude/skills/caption-animation/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/caption-animation/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about caption-animation

How do I install caption-animation in Claude?

Run npx skills add iart-ai/tiktok-video-skills --skill caption-animation -a claude-code. The skill lands at ~/.claude/skills/caption-animation/SKILL.md.

What does caption-animation do?

It builds captions with word-level timing: each word pops in on its syllable, the active word highlights, and the text stays inside the vertical safe area.

Can I use sentence-level SRT without re-transcribing?

No. The skill needs real word timestamps; splitting a line evenly across its duration produces visible drift.

Source text

SKILL.md · MIT
---
name: caption-animation
description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
version: 0.1.0
---

# Caption Animation

Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.

## When to use

- Word-by-word or karaoke captions on short-form vertical video (9:16).
- Burning open captions onto a voiceover/narration track from a transcript.
- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.

## The pipeline

| Stage | Job | Tool |
|---|---|---|
| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
| Place | Safe-area, readable type | layout rules below |
| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |

## Two non-negotiable rules

1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.

## Word timing from a transcript

Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:

```ts
// Caption token — the one shape everything downstream consumes
type Token = { text: string; startMs: number; endMs: number };

// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
const toMs = (t: string) => {
  const [h, m, rest] = t.split(":");
  const [s, ms] = rest.split(",");
  return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
```

If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.

## Per-word pop-in (Remotion)

Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".

```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";

const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const enter = (token.startMs / 1000) * fps;            // word's own entrance frame
  const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
  const scale = interpolate(p, [0, 1], [0.6, 1]);        // pop from 60% → 100% (overshoots)
  return (
    <span style={{
      display: "inline-block",
      transform: `scale(${scale})`,
      opacity: interpolate(p, [0, 1], [0, 1]),
      color: active ? "#FFE45E" : "#FFFFFF",             // active-word highlight
      transition: "color 80ms linear",
    }}>{token.text}&nbsp;</span>
  );
};
```

Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.

## Pages, not walls of text

Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.

```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({
  captions, combineTokensWithinMilliseconds: 1200,       // ~2–3 words per page
});
```

## Web/CSS pop (no Remotion)

For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:

```css
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
.word.active { color:#FFE45E; }
```
```js
words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
```

## Readable type and safe-area placement (9:16, 1080×1920)

| Property | Value | Why |
|---|---|---|
| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
| Stroke | 2–6px solid black outline | Survives any background |
| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
| Side safe | Keep within center 80% width | Avoids right-rail icons |

Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.

## Sync to a voiceover / narration

The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.

## Burn-in vs sidecar SRT/VTT

| | Burn-in (open) | Sidecar SRT/VTT (closed) |
|---|---|---|
| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |

Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.

## Output checklist

- Word-level timestamps (real, from transcription) — no even-split fakery.
- Per-word pop with micro-overshoot; one accent color for the active word.
- 1–4 words per page; never a wall of text.
- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
- Center band, clear of bottom ~280px and side rails.
- Audio laid under composition; timestamps in the audio timebase.
- Ship burn-in for social and a sidecar SRT/VTT for accessibility.

## Deliver & verify (rendered stills → MP4)

> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.

Captions ship as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.

**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.

```bash
# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
npx remotion still Captions out/f-start.png --frame=0   --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-mid.png   --frame=90  --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-end.png   --frame=N   --props='{"captionsSrc":"vo.json"}'  # N = durationInFrames-1

# Inspect each PNG:
#  - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
#  - burn-in legible: bold sans, stroke+shadow holds, no clipping
#  - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail

npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}'   # encode once stills are right
npx remotion render Captions out/demo.gif --codec=gif                                # README proof clip
```

Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.

**Before you finish:**
1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.

## Reference files

- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.

© iart-ai · MIT · iart-ai/tiktok-video-skills

Similar skills

Everything for Marketing and content →

From the same set4

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor