Char Stagger Then Scale-Up
The headline is on screen from the first frame — small and ghosted, reading as a label. Characters resolve one at a time from left to right, and just before the last one lands the whole word scales to full size with a `back.out` overshoot. It assembles itself, then it asserts itself. Supporting copy fades up a quarter of a second into the scale, so the page is never doing only one thing at a time.
Char Stagger Then Scale-Up
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/char-stagger-scale-up.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| text | string | — | The headline. Split per word and per character in JSX. |
| children | ReactNode | — | Supporting copy. Fades up after the scale has begun. |
| scaleFrom | number | = 0.42 | Starting scale of the whole word. |
| dimOpacity | number | = 0.24 | Starting opacity of each character. |
| charStagger | number | = 0.07 | Seconds between character reveals. |
| charDuration | number | = 0.28 | Seconds for one character to resolve. |
| scaleDuration | number | = 0.9 | Seconds for the scale-up. |
| overshoot | number | = 1.3 | The back.out(n) parameter — how far past 100% the word travels. |
| delay | number | = 0 | Seconds before the timeline starts. |
| startOnView | boolean | = true | Run when scrolled into view rather than on mount. |
| className | string | — | Merged onto the wrapper via cn. |
| textClassName | string | — | Merged onto the headline span — this is where type size and tracking go. |
How it works
The technique in order, not the code in order.
The text is split in JSX, not in the DOM:
text.split(" ")gives word boxes,Array.from(word)gives oneinline-blockspan per character. React owns every node, so no library mutates the tree and norevert()is needed to restore it.The idle state —
opacity: dimOpacityper character andtransform: scale(scaleFrom)on the word — is written as inline JSX styles, not by GSAP. That makes the server-rendered markup and the no-JS render identical to the pre-animation frame, and it means a parent re-render cannot wipe out what GSAP wrote, because React only ever diffs props against props.A
gsap.timelinefirst tweens[data-char]toopacity: 1withstagger: 0.07andduration: 0.28onpower1.out.The word scale is appended at position
">-0.05"— 50 ms *before* the character stagger ends. The overlap is what fuses two phases into one gesture; at">"they read as two separate animations queued back to back.Supporting copy (
[data-fade]) is appended at"<0.25"— 250 ms after the *scale started*, not after it finished. Relative-to-previous-start positioning is what keeps the choreography overlapping rather than sequential.transformOrigin: "50% 50%"is set explicitly on the scale tween even though it is the default: a heading inside a flex column can inherit an origin from an earlier transform on an ancestor, and the word then grows out of one corner.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| SCALE_FROM | = 0.42 | Starting scale of the word. Around 0.4 the text reads as a caption; past ~0.65 the growth is not large enough to register as a change of role, and below ~0.3 the characters are illegible while they stagger, so the first half of the animation is wasted. |
| DIM_OPACITY | = 0.24 | Resting strength of an unresolved character, as opacity on currentColor. Zero would make the reveal a pop-in and lose the ghosted-label reading; above ~0.4 there is not enough contrast between resolved and unresolved for the sweep to be visible. |
| CHAR_STAGGER | = 0.07 | Seconds between adjacent characters. A ten-character word takes 0.7s to sweep. Below ~0.04 the characters resolve as a block; above ~0.12 a long headline outlasts the reader's patience. |
| CHAR_DURATION | = 0.28 | Seconds for one character. Deliberately four times the stagger, so several characters are always mid-resolve — that overlap is what makes it a sweep rather than a sequence of individual pops. |
| SCALE_DURATION | = 0.9 | Seconds for the scale-up. back.out spends most of its curve on the settle, so a shorter duration compresses the overshoot into something that reads as a bounce artefact. |
| OVERSHOOT | = 1.3 | The back.out(n) parameter. Around 1.3 the word passes a few percent beyond full size and returns. At 1.7 (GSAP's default) the overshoot on large type is tens of pixels and reads as a wobble. |
| FADE_STAGGER / FADE_DURATION / FADE_Y | = 0.12 / 0.7 / 18 | Supporting copy rises 18px over 0.7s, 0.12s apart. The travel is small on purpose — body copy that moves as far as the headline competes with it. |
| VIEW_THRESHOLD | = 0.35 | Fraction visible before the timeline runs. A headline is tall; requiring more than about a third means it has to be nearly centred before anything happens, and requiring less starts it while it is still a sliver at the bottom edge. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The idle inline styles collapse to the finished state (scale(1), opacity: 1, no translate) and the effect returns before building a timeline. The headline and its supporting copy are fully rendered at full size on first paint — nothing is hidden and nothing waits for a trigger.
The timeline is built inside a gsap.context scoped to the root, and teardown calls ctx.revert(). That kills the tweens and strips the inline transform and opacity GSAP wrote, handing the nodes back in exactly the state React rendered — which matters under React 19 StrictMode, where the effect runs twice on mount.
On entering the viewport the characters brighten one at a time from left to right, and the word begins growing before the last character has finished. The word overshoots full size once and settles. Supporting copy starts rising while the word is still growing, not after. Unmount mid-animation and the nodes carry no inline transform or opacity. With reduced motion enabled the finished headline is present on first paint.
Pitfalls
- Splitting text before the webfont has loaded. Character and word boxes are measured against the fallback face, so widths and line breaks are wrong and never re-flow once the real font arrives — see
font-safe-text-splitfor the gating and re-split lifecycle this class of component needs. - Letting a splitting library mutate the DOM under React. React has no record of the injected spans, so the next parent render replaces the whole subtree and the animation targets nodes that are no longer in the document. Splitting in JSX avoids the problem entirely.
- Writing the idle state with
gsap.setin the effect. The first paint then shows the finished headline at full size before the effect runs, and it visibly snaps down before animating up. - Splitting straight to characters without word boxes. A line break can then land between two letters of the same word.
- Hardcoding the dim colour as a hex value.
#3d3d3dis invisible on a light background; opacity oncurrentColorcomposites correctly against any theme. - Queuing the scale at
">"instead of">-0.05". The two phases stop overlapping and the headline reads as "characters appear… then a separate thing happens". - Cleaning up with
kill()rather thanrevert().kill()stops the tweens but leaves GSAP's inlinetransformandopacityon the nodes, so a remount starts from the finished state.
Notes
- No text-splitting dependency: the split is declarative JSX. GSAP
SplitTextis reserved for the two components whose subject *is* SplitText (nested-splittext-restore,font-safe-text-split); everything else here splits in React so consumers are not forced to adopt a DOM-mutating splitter for a per-character stagger. - Each word is one
inline-block whitespace-nowrapbox with a real text node for the space between words, so lines still wrap normally between words.