Kinetic Typography in Remotion: The Complete Guide
Kinetic Typography in Remotion: The Complete Guide
Kinetic typography — text that moves, transforms, and dances on screen — is one of the most instantly recognizable styles in modern motion design. From product launch videos and social media reels to explainer animations and title sequences, animated text commands attention in ways that static text simply cannot.
Remotion is particularly well-suited to kinetic typography because text animation is ultimately a programming problem: take a string, split it into characters or words, assign each element a staggered timing offset, and apply physics-based transforms. What would require dozens of keyframes in a traditional motion graphics tool becomes a few dozen lines of TypeScript.
This guide walks through every level of kinetic typography in Remotion, from basic entrance patterns to per-character spring physics and organic noise-driven motion.
What Is Kinetic Typography?
Kinetic typography refers to animated text where the motion itself is part of the message. The animation style — fast and aggressive, soft and drifting, elastic and playful — communicates tone just as much as the words do.
Common kinetic typography patterns include:
- Entrance animations — characters or words appear from below, above, or from opacity zero
- Staggered reveals — each character or word enters slightly after the previous one
- Scale pulses — key words grow briefly to emphasize importance
- Path-following text — words drift along a curved path
- Per-character physics — each letter behaves as an independent spring-driven particle
The defining characteristic is that each text element is individually addressable. Rather than animating a whole paragraph as one unit, you split it into atoms — characters, syllables, or words — and animate each independently.
Setting Up the Character Split
The foundation of all character-level animation is splitting a string into an array of individual characters and rendering each in its own element. In React this is straightforward:
const text = 'Hello';
const chars = text.split('');
// ['H', 'e', 'l', 'l', 'o']
Wrap each character in a <span> with display: inline-block. This is critical — inline elements ignore transform, so you must use inline-block or block to apply translateY, scale, and rotate.
export const KineticTitle: React.FC = () => {
const text = 'Hello World';
const chars = text.split('');
return (
<div
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 96,
fontWeight: 700,
color: '#ffffff',
display: 'flex',
flexWrap: 'wrap',
}}
>
{chars.map((char, index) => (
<span
key={index}
style={{ display: 'inline-block' }}
>
{char === ' ' ? ' ' : char}
</span>
))}
</div>
);
};
The ' ' (non-breaking space) ensures space characters render at the correct width; a regular space inside inline-block collapses.
Character-by-Character Entrance with <Sequence>
<Sequence> is Remotion’s tool for time-offsetting content. Wrapping each character in a <Sequence> with a from offset staggered by index creates a natural cascade entrance.
import { Sequence, useCurrentFrame, spring, interpolate, useVideoConfig } from 'remotion';
const Character: React.FC<{ char: string; index: number }> = ({ char, index }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({
frame,
fps,
config: { mass: 0.5, stiffness: 200, damping: 14 },
});
const translateY = interpolate(enter, [0, 1], [40, 0]);
const opacity = interpolate(enter, [0, 1], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<span
style={{
display: 'inline-block',
transform: `translateY(${translateY}px)`,
opacity,
}}
>
{char === ' ' ? ' ' : char}
</span>
);
};
export const StaggeredTitle: React.FC = () => {
const text = 'Remotion';
const chars = text.split('');
const staggerFrames = 3; // frames between each character's entrance
return (
<div
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 120,
fontWeight: 800,
color: '#ffffff',
display: 'flex',
}}
>
{chars.map((char, index) => (
<Sequence key={index} from={index * staggerFrames} layout="none">
<Character char={char} index={index} />
</Sequence>
))}
</div>
);
};
The layout="none" prop on <Sequence> tells it not to render a wrapping <div>, so the character spans remain inline and the text flows naturally.
Each <Sequence from={index * staggerFrames}> means character 0 starts at frame 0, character 1 at frame 3, character 2 at frame 6, and so on. Inside the sequence, useCurrentFrame() starts from 0 at the sequence’s beginning, so the spring always starts fresh for each character.
Word-Level Animation
For longer copy or body text, animating at the word level is often more readable and less distracting than per-character animation. The structure is identical but the split is on spaces:
const text = 'Build videos with React';
const words = text.split(' ');
const staggerFrames = 6; // more frames between words for a slower cascade
const Word: React.FC<{ word: string }> = ({ word }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({
frame,
fps,
config: { mass: 0.8, stiffness: 100, damping: 12 },
});
const translateY = interpolate(enter, [0, 1], [30, 0]);
const opacity = enter; // spring value used directly as opacity 0→1
return (
<span
style={{
display: 'inline-block',
marginRight: '0.3em',
transform: `translateY(${translateY}px)`,
opacity,
}}
>
{word}
</span>
);
};
export const WordCascade: React.FC = () => {
const text = 'Build videos with React';
const words = text.split(' ');
return (
<div
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 64,
fontWeight: 700,
color: '#ffffff',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'baseline',
}}
>
{words.map((word, index) => (
<Sequence key={index} from={index * staggerFrames} layout="none">
<Word word={word} />
</Sequence>
))}
</div>
);
};
Adding marginRight: '0.3em' to each word span preserves word spacing because the original spaces have been consumed by the split(' ').
Spring Physics for Letter Motion
The quality of a kinetic typography animation depends heavily on the spring configuration. Different spring configs produce dramatically different feels.
Snappy and Energetic
config: { mass: 0.4, stiffness: 300, damping: 14 }
Characters pop into place almost instantly. Good for title cards that need to feel assertive and fast.
Elastic and Playful
config: { mass: 1, stiffness: 80, damping: 8 }
Characters bounce past their final position and oscillate back. Good for playful, youth-oriented content.
Smooth and Elegant
config: { mass: 1, stiffness: 100, damping: 20 }
Characters ease in with no overshoot. Closer to a traditional ease-out curve. Good for premium or editorial contexts.
Cascading Drop-In
Combine a translateY entrance with a slight rotate for a more dynamic drop:
const enter = spring({ frame, fps, config: { mass: 0.6, stiffness: 150, damping: 12 } });
const translateY = interpolate(enter, [0, 1], [-60, 0]);
const rotate = interpolate(enter, [0, 1], [-15, 0]);
const opacity = interpolate(enter, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
<span style={{
display: 'inline-block',
transform: `translateY(${translateY}px) rotate(${rotate}deg)`,
opacity,
}}>
{char}
</span>
Scale and Opacity Entrance Patterns
Beyond translateY, here are the most useful entrance transforms for kinetic typography:
Scale Up from Zero
const scale = spring({ frame, fps, from: 0, to: 1, config: { mass: 0.6, stiffness: 200, damping: 15 } });
const opacity = interpolate(scale, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
<span style={{ display: 'inline-block', transform: `scale(${scale})`, opacity }}>
{char}
</span>
Works best on display headlines where each character is large enough that the scale effect reads clearly.
Blur-In (CSS blur combined with opacity)
const enter = spring({ frame, fps, config: { damping: 18, stiffness: 90 } });
const blurAmount = interpolate(enter, [0, 1], [8, 0]);
const opacity = interpolate(enter, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
<span style={{
display: 'inline-block',
filter: `blur(${blurAmount}px)`,
opacity,
}}>
{char}
</span>
The blur filter is GPU-accelerated in most browsers and renders cleanly in Remotion’s Chromium-based renderer.
Horizontal Slide In
const enter = spring({ frame, fps, config: { mass: 0.8, stiffness: 120, damping: 14 } });
const translateX = interpolate(enter, [0, 1], [-40, 0]);
const opacity = enter;
<span style={{ display: 'inline-block', transform: `translateX(${translateX}px)`, opacity }}>
{char}
</span>
Useful for subtitle text where a gentle horizontal drift signals continuation.
Combining Multiple Animations in One Character
Advanced kinetic typography layers multiple transforms and properties simultaneously on the same element. Because all animations are driven by a single frame value, they are naturally synchronized:
const CharacterAdvanced: React.FC<{ char: string }> = ({ char }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Primary entrance spring
const enter = spring({
frame,
fps,
config: { mass: 0.5, stiffness: 180, damping: 12 },
});
// Secondary spring for a slight delay — feels like the letter swings into place
const settle = spring({
frame,
fps,
config: { mass: 1, stiffness: 60, damping: 10 },
});
const translateY = interpolate(enter, [0, 1], [50, 0]);
const rotate = interpolate(settle, [0, 1], [20, 0]);
const scale = interpolate(enter, [0, 1], [0.5, 1]);
const opacity = interpolate(enter, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
return (
<span
style={{
display: 'inline-block',
transform: `translateY(${translateY}px) rotate(${rotate}deg) scale(${scale})`,
opacity,
transformOrigin: 'bottom center',
}}
>
{char}
</span>
);
};
Setting transformOrigin: 'bottom center' makes the rotation pivot feel like the character is swinging upward from its base, which is more natural than rotating around the center.
Organic Motion with @remotion/noise
Spring-based animation is deterministic and precise, which is great for controlled entrances. But sometimes you want text that feels alive — slightly irregular, as if each character has its own subtle personality. @remotion/noise provides Perlin noise values that drift smoothly over time.
Install the package:
npm install @remotion/noise
Use noise2D to create position offsets that vary per-character and per-frame:
import { noise2D } from '@remotion/noise';
import { useCurrentFrame } from 'remotion';
const FloatingChar: React.FC<{ char: string; index: number }> = ({ char, index }) => {
const frame = useCurrentFrame();
// Each character gets a unique noise seed (the index)
// The noise value drifts slowly as the frame advances
const noiseX = noise2D('x-drift', index * 0.3, frame * 0.02) * 6;
const noiseY = noise2D('y-drift', index * 0.3, frame * 0.02) * 4;
const noiseRotate = noise2D('rotate', index * 0.3, frame * 0.015) * 3;
return (
<span
style={{
display: 'inline-block',
transform: `translate(${noiseX}px, ${noiseY}px) rotate(${noiseRotate}deg)`,
}}
>
{char}
</span>
);
};
The first argument to noise2D is a seed string — using different seeds for x, y, and rotation ensures they do not all move in the same direction at the same time. The second argument is the spatial dimension (here, the character index scaled down so adjacent characters have similar but distinct trajectories). The third argument is the time dimension.
Multiplying frame * 0.02 keeps the motion slow and smooth — higher multipliers produce faster, more chaotic drift.
Combining Entrance + Floating
The most effective approach is to combine a spring entrance with ongoing noise-driven floating:
const AnimatedChar: React.FC<{ char: string; index: number }> = ({ char, index }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Spring entrance
const enter = spring({
frame,
fps,
config: { mass: 0.5, stiffness: 200, damping: 14 },
});
const enterY = interpolate(enter, [0, 1], [40, 0]);
const opacity = interpolate(enter, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
// Ongoing float — only meaningful after the entrance completes
const floatY = noise2D('float', index * 0.5, frame * 0.01) * 5;
const floatRotate = noise2D('rot', index * 0.5, frame * 0.008) * 2;
return (
<span
style={{
display: 'inline-block',
transform: `translateY(${enterY + floatY}px) rotate(${floatRotate}deg)`,
opacity,
}}
>
{char}
</span>
);
};
The enterY + floatY sum means the character springs in from below and then continues to float gently once it has settled. The entrance overshoot from the spring and the ongoing noise-driven float blend naturally.
Practical Example: Full Kinetic Title Card
Putting everything together into a complete, production-ready component:
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion';
import { noise2D } from '@remotion/noise';
const Char: React.FC<{ char: string; index: number; total: number }> = ({
char,
index,
total,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({
frame,
fps,
config: { mass: 0.5, stiffness: 220, damping: 13 },
});
const translateY = interpolate(enter, [0, 1], [60, 0]);
const scale = interpolate(enter, [0, 1], [0.7, 1]);
const opacity = interpolate(enter, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
const floatY = noise2D('f', index / total, frame * 0.012) * 4;
return (
<span
style={{
display: 'inline-block',
transform: `translateY(${translateY + floatY}px) scale(${scale})`,
opacity,
color: '#ffffff',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 96,
fontWeight: 800,
lineHeight: 1.1,
letterSpacing: '0.02em',
}}
>
{char === ' ' ? ' ' : char}
</span>
);
};
export const KineticTitleCard: React.FC<{ title: string }> = ({ title }) => {
const chars = title.split('');
const stagger = 2; // frames between each character
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0a0a0a',
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center' }}>
{chars.map((char, i) => (
<Sequence key={i} from={i * stagger} layout="none">
<Char char={char} index={i} total={chars.length} />
</Sequence>
))}
</div>
</div>
);
};
Performance Considerations
Avoid Animating Too Many Characters Simultaneously
If you have 200+ characters on screen all running spring calculations per frame, performance can degrade in the Remotion Studio preview (though the final render is unaffected — it is deterministic and CPU-bound). Group characters into words or lines and animate at a coarser level for long bodies of text.
Use layout="none" on Sequences
Without layout="none", each <Sequence> renders a wrapping <div>. Wrapping each character in a <div> forces block layout on inline text, breaking the flow. Always use layout="none" for inline typography sequences.
Limit Noise Frequency
High time-frequency noise (e.g. frame * 0.3) produces jittery, high-frequency vibration. Keep the time multiplier below 0.05 for smooth, organic-feeling motion.
FAQ
Q: Why do I need display: inline-block on character spans?
CSS transform properties have no effect on inline elements. Without display: inline-block, any translateY, scale, or rotate applied via the style prop will be silently ignored. This is one of the most common reasons kinetic typography does not appear to animate.
Q: How do I handle multi-line text where I want each line to cascade separately?
Split the text into lines first, then split each line into characters. Wrap each line in its own container and offset the starting frame for each line. You can calculate the line’s start frame as lineIndex * lineStaggerFrames and the character’s start frame as charIndex * charStaggerFrames.
Q: Can I animate words while keeping the text selectable and accessible?
Wrapping each character in a <span> with inline-block preserves the DOM text, so screen readers can still read it. However, text selection across word boundaries may behave oddly due to the individual span wrapping. For video output, this is generally not a concern since the video is not interactive.
Q: Does @remotion/noise require an additional license?
No. @remotion/noise is part of the Remotion package ecosystem and follows the same license as Remotion itself. If your project is commercial, you need a Remotion commercial license, which covers all Remotion packages.
Q: How do I make text exit with animation, not just enter?
Use a second spring with a delay set near the end of the composition’s duration, or use the frame from useVideoConfig().durationInFrames - useCurrentFrame() as a countdown timer. Map a spring driven by this countdown to a reverse translateY and fade-out opacity.
Q: Is it possible to animate text along a curved path in Remotion?
Yes, but it requires SVG. Render each character positioned along a calculated point on a bezier curve, deriving the x/y coordinates mathematically from the curve formula and the character’s index. There is no built-in path-following utility, but the math is straightforward with a parametric curve function.
Q: What is the best stagger interval between characters?
For a 30fps video, 2–4 frames per character reads cleanly for title text (6–8 characters). For longer words or phrases, 1–2 frames per character is better to avoid the cascade taking too long. A stagger that takes more than about 1.5 seconds total to complete starts to feel slow.
Summary
Kinetic typography in Remotion follows a consistent pattern:
- Split your text into atoms (characters or words)
- Render each atom in a
<span style={{ display: 'inline-block' }}>inside a<Sequence from={index * staggerFrames} layout="none"> - Inside each atom’s component, drive transforms with a
spring()for entrance physics - Map the spring output to CSS properties using
interpolate() - Add
@remotion/noisefor organic, ongoing motion after the entrance
Explore professionally designed kinetic typography templates at RenderComp →
The RenderComp library includes production-ready kinetic title cards, word-reveal sequences, and character-cascade lower thirds — all built with tuned spring physics and editable source. Drop them into your project and customize the copy, colors, and timing without starting from scratch.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →