Files
hyperframes/skills/hyperframes-captions/dynamic-techniques.md
T
Vance IngallsandClaude Opus 4.6 afa5021b02 refactor(skills): extract audio-reactive into standalone skill
Audio reactivity applies to any visual element in a composition, not
just captions. Move it to its own skill referenced from compose-video.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:28:05 -07:00

9.9 KiB

Dynamic Caption Techniques

The default caption pattern — fade group in, hold, fade out — works but looks like subtitles. These techniques make captions feel designed and intentional. Mix them based on the content's energy. Every technique below is deterministic and works with HyperFrames' frame-by-frame rendering.

Per-Word Staggered Entrances

Each word in a group enters individually with staggered timing. The stagger creates a wave that follows the speaker's rhythm.

// Words enter one by one, timed to their speech timestamps
group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  tl.set(wordEl, { opacity: 0, y: 30, scale: 0.85 }, group.start);
  tl.to(
    wordEl,
    {
      opacity: 1,
      y: 0,
      scale: 1,
      duration: 0.18,
      ease: "back.out(1.7)",
    },
    word.start,
  );
});

Vary the entrance per word role. Content words (nouns, verbs) get scale + y. Function words (the, a, and) get opacity only — they shouldn't compete for attention.

Karaoke Highlight

All words in the group are visible from the start but muted. Each word transitions to full brightness as it's spoken. This gives the viewer reading context while directing attention to the current word.

// Show all words muted at group start
group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  tl.set(wordEl, { opacity: 0.3, scale: 0.95, color: "rgba(255,255,255,0.4)" }, group.start);
  // Light up when spoken
  tl.to(
    wordEl,
    {
      opacity: 1,
      scale: 1.05,
      color: "#ffffff",
      duration: 0.1,
      ease: "power2.out",
    },
    word.start,
  );
  // Settle after speaking
  tl.to(
    wordEl,
    {
      scale: 1,
      color: "rgba(255,255,255,0.85)",
      duration: 0.2,
      ease: "power1.out",
    },
    word.end,
  );
});

For high-energy content, add a color accent to the active word (color: accentColor) and a subtle glow (textShadow: "0 0 20px " + accentColor).

Clip-Path Reveals

Words or groups reveal through an animated clip-path rather than fading. This creates a physical, tactile feeling — like text being uncovered.

// Horizontal wipe: text sweeps in from left
tl.fromTo(
  groupEl,
  { clipPath: "inset(0 100% 0 0)" },
  { clipPath: "inset(0 0% 0 0)", duration: 0.4, ease: "power3.out" },
  group.start,
);

// Per-word vertical reveal: each word drops in from behind a mask
group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  tl.fromTo(
    wordEl,
    { clipPath: "inset(100% 0 0 0)", y: -10 },
    { clipPath: "inset(0% 0 0 0)", y: 0, duration: 0.2, ease: "power2.out" },
    word.start,
  );
});

// Circle reveal: text appears through an expanding circle
tl.fromTo(
  groupEl,
  { clipPath: "circle(0% at 50% 50%)" },
  { clipPath: "circle(100% at 50% 50%)", duration: 0.35, ease: "expo.out" },
  group.start,
);

Slam / Impact Words

Hero words slam onto the screen — they arrive fast, overshoot, and settle with weight. Reserve this for emphasis words (1-2 per group max). Over-using it kills the impact.

var isHeroWord = /^(LAUNCH|FREE|NOW|NEW|HUGE|INSANE)$/i.test(word.text);
if (isHeroWord) {
  tl.fromTo(
    wordEl,
    { scale: 2.5, opacity: 0, rotation: -8 },
    { scale: 1, opacity: 1, rotation: 0, duration: 0.25, ease: "back.out(2.5)" },
    word.start,
  );
  // Micro-shake on impact
  tl.to(wordEl, { x: 4, duration: 0.03 }, word.start + 0.25);
  tl.to(wordEl, { x: -3, duration: 0.03 }, word.start + 0.28);
  tl.to(wordEl, { x: 0, duration: 0.04, ease: "power2.out" }, word.start + 0.31);
} else {
  tl.fromTo(
    wordEl,
    { opacity: 0, y: 20 },
    { opacity: 1, y: 0, duration: 0.15, ease: "power2.out" },
    word.start,
  );
}

Absolute-Positioned Word Layout with Pretext

Position every word with position: absolute using pretext-measured widths. This unlocks animation paths that CSS inline flow can't do — words can fly in from any direction to their reading position.

var FONT = "900 72px Outfit";
var GAP = 14; // px between words
var containerWidth = 1600;

// Measure each word and compute its x position
var xCursor = 0;
var wordPositions = [];
group.words.forEach(function (word) {
  var prepared = window.__hyperframes.pretext.prepare(word.text.toUpperCase(), FONT);
  var measured = window.__hyperframes.pretext.layout(prepared, 9999, 72 * 1.2);
  var w = measured.height / 1.2;
  wordPositions.push({ x: xCursor, width: w });
  xCursor += w + GAP;
});

// Center the whole group
var totalWidth = xCursor - GAP;
var offsetX = (containerWidth - totalWidth) / 2;

group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  var finalX = wordPositions[wi].x + offsetX;
  wordEl.style.position = "absolute";
  wordEl.style.left = finalX + "px";

  // Scatter entrance: each word arrives from a unique direction
  var angle = (wi / group.words.length) * Math.PI * 2;
  var radius = 300;
  var startX = finalX + Math.cos(angle) * radius;
  var startY = Math.sin(angle) * radius;

  tl.fromTo(
    wordEl,
    { x: startX - finalX, y: startY, opacity: 0, scale: 0.5 },
    { x: 0, y: 0, opacity: 1, scale: 1, duration: 0.35, ease: "back.out(1.4)" },
    word.start,
  );
});

Elastic / Spring Entrances

Words arrive with physics — they overshoot their target and oscillate before settling. Different spring constants per word create an organic, staggered feeling.

group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  // Vary elasticity by word position — earlier words bouncier
  var elasticity = 0.3 + wi * 0.05;
  var amplitude = 1.2 - wi * 0.1;

  tl.fromTo(
    wordEl,
    { y: 60, opacity: 0, scaleY: 1.3, scaleX: 0.85 },
    {
      y: 0,
      opacity: 1,
      scaleY: 1,
      scaleX: 1,
      duration: 0.5,
      ease: "elastic.out(" + amplitude + ", " + elasticity + ")",
    },
    word.start,
  );
});

Rotation & 3D Perspective

Words rotate into view on the X or Y axis, creating a sense of depth. Requires transformPerspective on the parent for 3D effect.

// Set perspective on the group container
gsap.set(groupEl, { transformPerspective: 800 });

group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  // Alternate rotation direction per word
  var rotDir = wi % 2 === 0 ? 90 : -90;
  tl.fromTo(
    wordEl,
    { rotationX: rotDir, opacity: 0, transformOrigin: "50% 100%" },
    { rotationX: 0, opacity: 1, duration: 0.3, ease: "power3.out" },
    word.start,
  );
});

Kinetic Exit Patterns

Exits are as important as entrances. Don't always fade out — give words somewhere to go.

// Scatter exit: words fly apart when the group ends
group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  var angle = (wi / group.words.length) * Math.PI * 2;
  var exitX = Math.cos(angle) * 200;
  var exitY = Math.sin(angle) * 150;
  tl.to(
    wordEl,
    {
      x: exitX,
      y: exitY,
      opacity: 0,
      scale: 0.6,
      rotation: wi % 2 ? 15 : -15,
      duration: 0.2,
      ease: "power3.in",
    },
    group.end - 0.2,
  );
});
// Hard kill still required
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);

// Collapse exit: words squeeze together then vanish
tl.to(
  groupEl.querySelectorAll("span"),
  {
    letterSpacing: "-0.15em",
    scaleX: 0.7,
    opacity: 0,
    duration: 0.15,
    ease: "power2.in",
    stagger: { each: 0.02, from: "edges" },
  },
  group.end - 0.2,
);

// Drop exit: words fall with gravity
group.words.forEach(function (word, wi) {
  var wordEl = document.getElementById("w-" + gi + "-" + wi);
  tl.to(
    wordEl,
    {
      y: 300,
      rotation: 10 + wi * 5,
      opacity: 0,
      duration: 0.3,
      ease: "power2.in",
    },
    group.end - 0.3 + wi * 0.03,
  );
});

Combining Techniques

The best dynamic captions layer 2-3 techniques together. A few combinations that work:

Combination Energy Best for
Karaoke highlight + audio reactivity Medium-high Music videos, lyric videos
Staggered entrance + scatter exit High Hype content, trailers
Clip-path reveal + fade exit Medium Corporate, storytelling
Slam heroes + elastic others + drop exit Very high Product launches, announcements
3D rotation entrance + collapse exit Medium-high Tech, modern brands

Don't combine slam entrances with elastic entrances on the same group — pick one motion personality per group. You can vary techniques across groups to match the content's pace changes.

Width-Aware Grouping with Pretext

Instead of grouping by word count alone, use pretext to group by visual width. This prevents some groups from filling the frame while others use 30%.

var FONT = "900 72px Outfit";
var MAX_WIDTH = 1500; // slightly under container to leave padding

var groups = [];
var currentGroup = { words: [], text: "" };

words.forEach(function (word) {
  var testText = (currentGroup.text + " " + word.text).trim().toUpperCase();
  var result = window.__hyperframes.fitTextFontSize(testText, {
    fontFamily: "Outfit",
    fontWeight: 900,
    maxWidth: MAX_WIDTH,
    baseFontSize: 72,
    minFontSize: 72,
    step: 2,
  });

  if (!result.fits && currentGroup.words.length > 0) {
    // Adding this word would overflow — start new group
    groups.push(currentGroup);
    currentGroup = { words: [word], text: word.text };
  } else {
    currentGroup.words.push(word);
    currentGroup.text = testText;
  }
});
if (currentGroup.words.length > 0) groups.push(currentGroup);

This replaces the fixed "3-5 words per group" heuristic with pixel-accurate measurement. "I" and "EXTRAORDINARY" take very different widths — pretext accounts for that.