Files
AISkills/kinetic-typography/SKILL.md
T
Soroush Asadi 6cf6d8953f feat: design+motion R&D report and 6 professional craft skills
R&D brief (references/design-motion-rnd.md): 2024-2026 design/motion trends,
animating-anything craft, Iran-aware asset pipeline, masterpiece + platform playbook.

New craft skills: motion-design-principles, scene-transitions, kinetic-typography,
video-hooks, particles-and-effects, asset-sourcing — grounded in the Remotion stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:09:03 +03:30

96 lines
8.2 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 700900) and `lineHeight: 1.41.6`. Numerals: pick Persian (۱۲۳ via `toLocaleString('fa-IR')`) or Latin and stay consistent; prices/years are usually Persian digits. See `persian-fonts`.
- 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(80110)`, body ≈ `vmin(2840)`. 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 = <T,>(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 `<Sequence>`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 `<textPath href="#p">`; 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 = <T,>(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 (
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center",
direction: "rtl", fontFamily: FONT, fontWeight: 800, fontSize: L.vmin(96),
lineHeight: 1.4, color, gap: `0 ${L.vmin(18)}px`, maxWidth: "86%",
textShadow: `0 ${L.vmin(2)}px ${L.vmin(20)}px rgba(0,0,0,.6)` }}>
{words.map((w, i) => {
const s = spring({ frame: frame - start - i * stagger, fps,
config: { damping: 16, mass: 0.7, stiffness: 120 } });
return (
<span key={i} style={{ display: "inline-block", opacity: s,
transform: `translateY(${interpolate(s, [0, 1], [L.vmin(28), 0])}px)` }}>
{w}
</span>
);
})}
</div>
);
};
```
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 814f · word stagger 24f · standard reveal 1828f · hero entrance 2840f · **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`.
## 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`, `persian-fonts`, `remotion-aspect-ratios`, `remotion-design-styles`, `remotion-svg-colors`, `remotion-sound-effects`, `remotion-music-picker`, `flatrender-template-seo`.