Text Swap
Replace one line of text with another — by default the old line rushes the camera and flies past you, and the new one is revealed behind it
Installation
$ pnpm dlx shadcn@latest add @snapcn/text-swapComponent code
The exact file shadcn add copies into your project.
Usage
// src/Root.tsx
import { Composition } from "remotion";
import { TextSwap } from "@/components/snap-cn/text-swap";
const TextSwapScene = () => (
<TextSwap
fromText="Manual invoicing"
toText="Automated with Acme"
transition="fly-through"
fontSize={72}
/>
);
export const RemotionRoot = () => (
<Composition
id="TextSwap"
component={TextSwapScene}
durationInFrames={90}
fps={30}
width={1280}
height={720}
/>
);Two stacked layers hold the outgoing and incoming lines. The incoming line is
scheduled with enterStart = max(0, exitTotal - overlap + microDelay), so the
old text is (almost) gone before the new one lands — with a small beat between
the two moments.
Presets
The transition prop selects one of six looks. Each preset carries its own
default unit, exitDuration, and enterDuration; setting those props
explicitly overrides the preset.
| Preset | Description |
|---|---|
fly-through | Default. The old line rushes the camera and passes your face; the new one is revealed behind it, arriving out of depth. |
fade-through | Material-style block fade: old line fades out with a small upward drift, new line fades in with a soft blur resolve. |
crossfade | Words gently crossfade one after another with a short vertical drift — a calm keynote rhythm. |
shared-axis-y | The whole line travels on the vertical axis: exits 24px up, enters from 24px below. |
shared-axis-z | Depth swap: the old line scales up and blurs away, the new one settles down from a larger scale. |
cut | Per-word hard cuts with stepped staircase timing for sharp editorial swaps. |
How fly-through works, and why it is not just a big scale
Three things, and each of them is load-bearing.
Perspective, not easing. The line does not grow on a ramp — it travels toward
the eye, and its apparent size goes as 1 / (1 - travel). So it creeps for most
of the trip and then blows up right at the end, which is the whole character of
something rushing past you. This cannot be faked with an easing curve. Fitted
against the reference recording, a plain interpolate to a big scale misses by
rmse 0.31 and can never reach the blowup at all; the perspective model lands at
10.09x where the reference measured 10.28x. The easing describes the travel,
which is nearly linear — the drama is geometry, not curve-shaping.
A shutter, not a blur filter. At the end of the rush the line more than doubles
between one frame and the next. Drawn sharp, that does not read as speed — it
reads as strobing. So the exit is sampled several times across the frame and the
samples are averaged, which is exactly what a camera shutter does. Because the
motion is a scale, the samples fan out radially: sharp at the centre of the rush,
streaked at the edges. That radial smear is the thing that makes it feel fast, and
you cannot get it from filter: blur(), which softens everything equally.
It stays solid until it is gone. A line flying at your face does not dim on the
way in. exitFadeStart holds the fade back until 72% of the travel has passed,
and then it goes.
The cost is real: the shutter draws the outgoing line 18 times per frame. That is free in a render and expensive in a live browser preview, which is why this component ships a pre-rendered demo on the docs site.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
fromTextrequired | string | — | The line that exits |
toTextrequired | string | — | The line that enters |
transition | "fade-through" | "crossfade" | "shared-axis-y" | "shared-axis-z" | "cut" | "fade-through" | Which swap preset to use |
unit | "word" | "block" | per transition | Animate word-by-word or as one block. Defaults to "word" for crossfade and cut, "block" otherwise |
exitDuration | number | per transition (8–15) | Frames each outgoing segment takes to exit |
enterDuration | number | per transition (8–21) | Frames each incoming segment takes to enter |
exitStagger | number | 1 | Frame offset between outgoing words (unit="word" only) |
enterStagger | number | 2 | Frame offset between incoming words (unit="word" only) |
overlap | number | 1 | Frames the enter is pulled forward into the exit |
microDelay | number | 2 | Extra beat (frames) added before the enter starts |
fontSize | number | 72 | Font size in pixels |
color | string | theme.foreground | Text color (any valid CSS color) |
fontWeight | number | 600 | CSS font-weight |
speed | number | 1 | Playback speed multiplier |
className | string | — | Optional className passed to the root container |
theme | Partial<SnapCnTheme> | — | Design-system token overrides. Anything you leave out falls back to the shadcn defaults |
mode | "light" | "dark" | — | Which end of the design system to resolve tokens against |