--- name: kinetic-typography description: How to build animated-text systems for FlatRender Remotion templates — word/line/char reveals, mask wipes, typewriter, scale-pops, highlight sweeps, text-on-path, and number counters — Persian/RTL-aware and reusable. Use whenever a template's hero, caption, quote, title, price, or any text is the thing that moves. Persian is the priority; split by WORD, never by character. --- # Kinetic typography (animated text systems) Type is a first-class motion element here, not a label. A masterpiece text shot is ~5 layers: the right split, eased per-unit timing, a hold sized to a real read, legibility over the background, and a single hero word. Amateurs stop at "the text fades in." ## The one rule Every value is a pure function of `useCurrentFrame()`. **Never** `useFrame`, `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion — the headless renderer samples frames out of order. For "random" jitter use `rand(seed)` from `lib/anim.ts`. Drive timing off `useVideoConfig().fps`; define `const sec = (s: number) => Math.round(s * fps)` — never hardcode `30`. ## Persian / RTL — get this right first (it's an Iran-facing product) - **Split by WORD, not character.** Persian script is connected/cursive — splitting on chars shatters letterforms and joins. Latin char-reveals are fine; Persian is word- or line-only. A safe split is `text.split(/\s+/).filter(Boolean)` — this **preserves ZWNJ** (نیم‌فاصله, `‌`) inside words like «می‌شود» because ZWNJ is not whitespace. Never `.split("")` or `.replace(/‌/g, …)` on Persian. - Every text node: `fontFamily: FONT` (Vazirmatn, from `lib/fonts.ts`), `direction: "rtl"`, align right or center. The existing `KineticQuote.tsx` hardcodes Georgia/serif + pixel sizes + no RTL — **do not copy that**; it's a Latin-only relic. - Persian needs weight (headings 700–900) and `lineHeight: 1.4–1.6`. Numerals: pick Persian (۱۲۳ via `toLocaleString('fa-IR')`) or Latin and stay consistent; prices/years are usually Persian digits. See `../persian-fonts/SKILL.md`. - For RTL word reveals, the wrapping container does the ordering — keep `flexWrap: "wrap"` + `direction: "rtl"` and let words flow; don't manually reverse the array. ## Size & position from layout tokens, never pixels Read `useLayout()` from `lib/aspect.ts`: `vmin(n)`, `unit`, `isWide/isSquare/isTall`. Hero type ≈ `vmin(80–110)`, body ≈ `vmin(28–40)`. Tune timing/scale per aspect — wider reads faster (tighter stagger), tall reads slower (looser). Add this `pick` helper to `Layout` (per R&D Tier-0) and use it: ```ts const pick = (w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t); const stagger = pick(2, 3, 4); // frames between units ``` ## Animation patterns (all driven by `frame - start`) | Pattern | Recipe | Persian-safe? | |---|---|---| | **Word reveal** (default) | split words; per word `start = i*stagger`; `spring({frame: frame-start, fps})` → `translateY(vmin)` + `opacity` | ✅ word-split | | **Line reveal** | wrap by line in ``s; each line springs up behind a `clip-path` edge | ✅ | | **Char reveal / scatter** | split chars, per-char delay; rotate/scale in | ❌ Latin only | | **Mask wipe** | `clipPath: inset(0 ${100-p}% 0 0)` (RTL: wipe from right → `0 0 0 ${100-p}%`); `p = interpolate(frame,[a,b],[0,100],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)})` | ✅ | | **Typewriter** | `text.slice(0, Math.floor(interpolate(frame,[a,b],[0, words.length])))` joined — **slice by WORD for Persian**, by char only for Latin; add a blinking caret `frame % sec(0.8) < sec(0.4)` | ✅ word-slice | | **Scale-pop ("ta-da")** | `scale = spring({config:{damping:12,mass:0.6,stiffness:180}})` or `Easing.bezier(0.34,1.56,0.64,1)` overshoot→settle | ✅ | | **Highlight sweep** | gradient bar/`background-clip:text` shifting `background-position` per frame, or an accent rect growing under a key word | ✅ | | **Text-on-path** | SVG ``; animate `startOffset` by frame — Latin/numeric only (RTL on a path is unreliable) | ❌ | | **Number counter** | `Math.round(interpolate(frame,[a,b],[0, target],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)}))` then `toLocaleString('fa-IR')` | ✅ (format fa) | | **Variable-weight pulse** | Vazirmatn ships a variable axis: `fontVariationSettings: \`'wght' ${interpolate(frame,[a,b],[300,900])}\`` (needs the variable woff2 registered in `fonts.ts`) | ✅ | ### Reusable word-reveal component (the workhorse — Persian-correct, aspect-aware) ```tsx const RevealText: React.FC<{ text: string; start: number; color: string }> = ({ text, start, color }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const L = useLayout(); const pick = (w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t); const stagger = pick(2, 3, 4); const words = text.split(/\s+/).filter(Boolean); // keeps ZWNJ return (
{words.map((w, i) => { const s = spring({ frame: frame - start - i * stagger, fps, config: { damping: 16, mass: 0.7, stiffness: 120 } }); return ( {w} ); })}
); }; ``` Follow-through upgrade: give a trailing accent word a *looser* spring (`damping: 6`) so it settles last. ## Easing & spring (linear is the sound of an amateur) - Entrances → **ease-out** default (`Easing.out(Easing.cubic)`); hero titles → `Easing.bezier(0.16,1,0.3,1)`. Exits → **ease-in, sharper than the entrance**. Snappy pop → back bezier `(0.34,1.56,0.64,1)`. - `interpolate` for exact marks — **always `extrapolateLeft/Right: "clamp"`** (forgetting it is the #1 drift bug). `spring` for organic feel. Combine: `interpolate(spring(...), [0,1], [vmin(28), 0])`. - Spring cheats: clean reveal `{damping:200,mass:0.5,stiffness:200}` · default pop `{damping:12,mass:0.6,stiffness:180}` · bouncy `{damping:8,mass:1,stiffness:120}` · trailing wobble `{damping:6,mass:1,stiffness:80}`. ## Timing budgets (@ whatever `fps` is) Micro pop 8–14f · word stagger 2–4f · standard reveal 18–28f · hero entrance 28–40f · **hold = a comfortable read** (≥ `sec(0.7)` per text element before the next competes). Cut frames before adding them — over-animating reads as amateur. Anticipation: dip below start before launch (`interpolate(frame,[0,6,30],[0,-0.12,1])`). ## Legibility over busy / 3D / video backgrounds - Scrim or `textShadow: 0 0 vmin(20) rgba(0,0,0,.7)`, or a semi-transparent panel behind text. - Gradient text: `WebkitBackgroundClip: "text"`, transparent fill, plus a `drop-shadow` for edge separation. - Colors come from `colorSchema` props (`accentColor/secondaryColor/backgroundColor/textColor` via `lib/branding.ts`) — pass user hex through `mixHex`/`hexToRgba` so a garish value doesn't break the look. Never hardcode `#fff`. - Captions (TikTok/Reels/Shorts) = high-contrast white/yellow + black outline, lower-middle third, inside the tightest safe zone. See `../remotion-aspect-ratios/SKILL.md`. ## Checklist - [ ] Persian text split by WORD; ZWNJ preserved; `direction:"rtl"` + `fontFamily: FONT`. - [ ] All sizes via `vmin`/`unit`; timing/stagger via `pick(...)` per aspect — verified in 16:9, 1:1, 9:16. - [ ] No linear easing; ≥1 overshoot-and-settle; staggered, not all on frame 0. - [ ] Every `interpolate` clamps both ends; no `useFrame`/`random`/`Date.now`; `fps` not `30`. - [ ] Numbers formatted (`fa-IR`) and consistent; counter eases out. - [ ] Legible over the background (scrim/shadow); colors from props. - [ ] A real hold sized to reading; longest Persian string doesn't overflow, shortest doesn't look empty. - [ ] Re-render twice → identical pixels (deterministic). Related: `../remotion-template-composition/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-design-styles/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../flatrender-template-seo/SKILL.md`.