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>
8.2 KiB
name, description
| name | description |
|---|---|
| kinetic-typography | 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, fromlib/fonts.ts),direction: "rtl", align right or center. The existingKineticQuote.tsxhardcodes 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 (۱۲۳ viatoLocaleString('fa-IR')) or Latin and stay consistent; prices/years are usually Persian digits. Seepersian-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(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:
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 infonts.ts`) |
✅ |
Reusable word-reveal component (the workhorse — Persian-correct, aspect-aware)
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). interpolatefor exact marks — alwaysextrapolateLeft/Right: "clamp"(forgetting it is the #1 drift bug).springfor 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 adrop-shadowfor edge separation. - Colors come from
colorSchemaprops (accentColor/secondaryColor/backgroundColor/textColorvialib/branding.ts) — pass user hex throughmixHex/hexToRgbaso 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 viapick(...)per aspect — verified in 16:9, 1:1, 9:16. - No linear easing; ≥1 overshoot-and-settle; staggered, not all on frame 0.
- Every
interpolateclamps both ends; nouseFrame/random/Date.now;fpsnot30. - 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.