Overview
TextEngine splits text into letter, word, and line animation slots, each driven by independent react-spring springs. Mixed children are fully supported — plain strings animate alongside React elements, and non-text nodes (SVG icons, images) are treated as single word units.
Installation
npm install spring-text-engine # peer dependency npm install @react-spring/web
Import
import TextEngine from 'spring-text-engine';
import type { TextEngineInstance, EngineProps } from 'spring-text-engine';
// named imports
import { TextEngine, ProgressTrigger, tengine } from 'spring-text-engine';Quick start
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function Hero() {
return (
<TextEngine
tag="h1"
mode="once"
overflow
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 60, opacity: 0 }}
lineStagger={100}
lineConfig={{ duration: 900, easing: easings.easeOutCubic }}
>
The quick brown fox
</TextEngine>
);
}Animation Layers
Each word is wrapped in up to 6 nested layers. Layers render only when their corresponding *In prop is non-empty, keeping the DOM flat when a layer is unused.
<wrapLine> ← overflow clip + line-level spring
<line> ← line spring (all words on same line share same delay)
<wrapWord> ← overflow clip + word spring
<word> ← word spring
<wrapLetter> ← per-letter overflow clip
<letter> ← per-letter spring
</wrapLetter>
</word>
</wrapWord>
</line>
</wrapLine>Out target to the resting / hidden position (e.g. { y: 60, opacity: 0 }) and the In target to the final visible position ({ y: 0, opacity: 1 }). The engine animates Out → In on enter, and In → Out on exit.wrapLine, wrapWord, wrapLetter) share their spring config and stagger with their corresponding content layer. wrapWord uses wordConfig / wordStagger, etc.Modes
modeThe mode prop controls when animation plays. Default is "always".
| Mode | Behavior |
|---|---|
always | Plays in when entering viewport, plays out when leaving. Repeats every time. (default) |
once | Plays in the first time the element enters the viewport. Never replays. |
forward | Plays in on downward scroll into view. Does not replay if the user scrolls back up and down again. |
manual | No automatic trigger. Control via instance.playIn(), instance.playOut(), instance.togglePause(), or by writing 0–1 to instance.progress.current. |
progress | Animation is driven by scroll position between start and end trigger positions. Sub-modes: type="toggle" (snap) or type="interpolate" (smooth). |
Animation Values
These are the CSS / transform properties you can put inside any *In / *Out target object. React-spring animates between the Out value and the In value for each frame.
| Prop | Unit / Type | Description |
|---|---|---|
y | px (number) | Vertical translation. Positive = down, negative = up. Compiles to translateY(). |
x | px (number) | Horizontal translation. Compiles to translateX(). |
opacity | 0–1 | CSS opacity. |
scale | number | CSS scale(). 0 = invisible, 1 = normal, 2 = double. |
rotate | deg (number) | Rotation around Z axis. Compiles to rotateZ(). |
rotateX | deg (number) | 3D rotation around X axis. Use with a parent perspective for best results. |
rotateY | deg (number) | 3D rotation around Y axis. |
skewX | deg (number) | Skew on X axis. |
skewY | deg (number) | Skew on Y axis. |
filter | CSS string | CSS filter string. React-spring interpolates numeric values inside. E.g. 'blur(0px)' ↔ 'blur(16px)'. Both In and Out must use the same filter function. |
color | hex / rgb | CSS text color. React-spring interpolates smoothly between hex/rgb values. |
| `y` (string) | '0%' / '100%' | Percentage-based translateY. Must be string on both In and Out (can't mix number and string). |
lineIn/lineOut, wordIn/wordOut, letterIn/letterOut, wrapLineIn/wrapLineOut, wrapWordIn/wrapWordOut, wrapLetterIn/wrapLetterOut.Spring Config
Spring configs control the physics or timing of animations. Each level has a shared config (applies to both in and out) and optional directional overrides that take precedence.
| Prop | Applies to | Description |
|---|---|---|
lineConfig | line + wrapLine in & out | Shared spring config for line layer. |
lineConfigIn | line + wrapLine enter only | Overrides lineConfig for enter animation. |
lineConfigOut | line + wrapLine exit only | Overrides lineConfig for exit animation. |
wordConfig | word + wrapWord in & out | Shared spring config for word layer. |
wordConfigIn / wordConfigOut | word + wrapWord | Per-direction word configs. |
letterConfig | letter + wrapLetter in & out | Shared spring config for letter layer. |
letterConfigIn / letterConfigOut | letter + wrapLetter | Per-direction letter configs. |
SpringConfig properties
| Property | Type / Default | Description |
|---|---|---|
tension | number / 170 | Spring stiffness. Higher = faster and snappier. Used in spring mode. |
friction | number / 26 | Spring damping. Lower = more bounce / oscillation. Used in spring mode. |
mass | number / 1 | Simulated mass. Higher = slower, heavier feel. |
duration | ms | Fixed duration in milliseconds. When set, switches from physics to time-based animation. |
easing | easings.* function | Easing function from @react-spring/web. Used with duration. E.g. easings.easeOutCubic. |
import { easings } from '@react-spring/web';
// Physics-based spring
wordConfig={{ tension: 200, friction: 18 }}
// Time-based with easing
lineConfig={{ duration: 800, easing: easings.easeOutQuart }}
// Per-direction override: instant out, spring in
letterConfigIn={{ tension: 220, friction: 20 }}
letterConfigOut={{ duration: 0 }}Timing & Stagger
Stagger adds a cascading delay between elements. Delay adds a global wait before any element starts. All values are in milliseconds.
Global delays
| Prop | Default | Description |
|---|---|---|
delayIn | 0 | Global delay before the entire enter animation starts. |
delayOut | 0 | Global delay before the entire exit animation starts. |
lineDelayIn / lineDelayOut | 0 | Extra per-layer delay added on top of the global delay. |
wordDelayIn / wordDelayOut | 0 | |
letterDelayIn / letterDelayOut | 0 |
Stagger
| Prop | Default | Description |
|---|---|---|
lineStagger | 0 | Per-line delay offset. All words on the same line share the same line index. |
lineStaggerIn / lineStaggerOut | 0 | Override stagger for one direction only. |
wordStagger | 0 | Per-word delay offset. Words are indexed sequentially across the whole text. |
wordStaggerIn / wordStaggerOut | 0 | |
letterStagger | 0 | Per-letter delay offset. Letters are indexed sequentially across the whole text. |
letterStaggerIn / letterStaggerOut | 0 |
Behaviour flags
| Prop | Default | Description |
|---|---|---|
immediateOut | true | Exit animation is instant — no spring, no stagger. Set false for a full animated exit. |
enableInOutDelayesOnRerender | false | Apply delayIn / delayOut when children text changes reactively. Normally delays are skipped on rerenders to keep transitions snappy. |
Wrap Layers
Wrap layers are containers that sit around each line, word, or letter. They animate with the same spring config and stagger as their content layer, but are driven by their own independent spring — allowing opposing or complementary transforms.
| Prop pair | Config shared with | When renders |
|---|---|---|
wrapLineIn / wrapLineOut | lineConfig`, `lineStagger | When wrapLineIn is non-empty |
wrapWordIn / wrapWordOut | wordConfig`, `wordStagger | When wrapWordIn is non-empty |
wrapLetterIn / wrapLetterOut | letterConfig`, `letterStagger | When wrapLetterIn is non-empty |
Overflow clip pattern
Set overflow: 'hidden' on the wrap layer to clip content — the letter/word slides inside the masked boundary:
<TextEngine
overflow
wrapLetterIn={{ overflow: 'hidden' }}
letterIn={{ y: '0%', opacity: 1 }}
letterOut={{ y: '100%', opacity: 0 }}
letterStagger={35}
letterConfig={{ tension: 180, friction: 20 }}
>
Clipped inside each letter.
</TextEngine>Opposing transform pattern
Animate wrap and content in opposite directions for striking effects:
// Wrapper falls from above, letters rise from below
wrapWordIn={{ y: 0, opacity: 1 }}
wrapWordOut={{ y: -50, opacity: 0 }}
wordConfig={{ tension: 160, friction: 16 }}
letterIn={{ opacity: 1, y: 0 }}
letterOut={{ opacity: 0, y: 24 }}
letterStagger={14}
letterConfig={{ tension: 280, friction: 22 }}
// Wrapper + content counter-skew (parallelogram shear)
wrapWordIn={{ skewX: 0 }}
wrapWordOut={{ skewX: -20 }}
wordIn={{ opacity: 1, skewX: 0 }}
wordOut={{ opacity: 0, skewX: 20 }}Progress Mode
mode="progress"Wires animation directly to scroll position between start and end trigger points. No manual scroll handling needed.
| Prop | Type / Default | Description |
|---|---|---|
type | "toggle" | "interpolate" / "toggle" | toggle: each unit snaps in/out as scroll crosses its stagger threshold. interpolate: each unit smoothly interpolates with scroll position. |
start | TriggerPos / "top bottom" | Scroll position where progress = 0. |
end | TriggerPos / "bottom top" | Scroll position where progress = 1. |
interpolationStaggerCoefficient | number / 0.3 | In interpolate mode: spreads out per-unit progress windows. 0 = all animate at once. Higher = each unit animates later than the previous. |
trigger | RefObject<HTMLElement> | Use an external element as the scroll reference instead of the component itself. |
TriggerPos format
"element-edge viewport-edge" — both edges must be one of top | center | bottom. Optional pixel offset with +=N or -=N.
| Example | Meaning |
|---|---|
"top bottom" | Progress = 0 when element top reaches viewport bottom (element starts entering from below) |
"bottom top" | Progress = 1 when element bottom reaches viewport top (element fully scrolled past) |
"center center" | Trigger when element center aligns with viewport center |
"top bottom+=200" | Starts 200 px after element top would hit viewport bottom (triggers later) |
"bottom top-=100" | Ends 100 px before element bottom reaches viewport top (ends earlier) |
// Toggle — words snap in as you scroll
<TextEngine
mode="progress"
type="toggle"
start="top bottom"
end="top center"
wordIn={{ opacity: 1, y: 0 }}
wordOut={{ opacity: 0, y: 20 }}
wordStagger={60}
wordConfig={{ tension: 180, friction: 20 }}
>
Snaps in as you scroll past.
</TextEngine>
// Interpolate — letters blur away with scroll
<TextEngine
mode="progress"
type="interpolate"
interpolationStaggerCoefficient={0.25}
start="top bottom"
end="center center"
letterIn={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
letterOut={{ opacity: 0, y: 40, filter: 'blur(16px)' }}
letterStagger={25}
>
Blur fades with your scroll.
</TextEngine>SEO & Accessibility
When seo is true (the default), TextEngine renders a visually-hidden copy of the text using the standard accessible visually-hidden pattern. Crawlers and screen readers see the unmodified text; the animated layer is marked aria-hidden="true".
| Prop | Default | Description |
|---|---|---|
seo | true | Renders a visually-hidden copy using position:absolute / clip:rect(0,0,0,0) pattern. The animated wrapper receives aria-hidden="true". Set false to disable. |
Class Names & Core Props
Core structural props and CSS class hooks for each DOM layer. Useful for hover effects, debugging element boundaries, or targeting with CSS animations.
Core props
| Prop | Type / Default | Description |
|---|---|---|
tag | HtmlTag / "span" | HTML tag for the container element. Choose semantically (h1, p, span…). |
enabled | boolean / true | Master toggle. When false, text renders static in the In state. |
overflow | boolean / false | Sets overflow:hidden on wrapLine and wrapWord containers. |
columnGap | number | "inherit" / 0.3 | Gap between words in em units. |
rootMargin | string / "0px" | IntersectionObserver rootMargin for non-progress modes. E.g. "-100px 0px" triggers later. |
Class name props
| Prop | Applied to |
|---|---|
className | Root container element |
wrapLineClassName | Every wrapLine span |
lineClassName | Every animated line span |
wrapWordClassName | Every wrapWord span |
wordClassName | Every animated word span |
wrapLetterClassName | Every wrapLetter span |
letterClassName | Every animated letter span |
Callbacks
Lifecycle callbacks fired at different stages. Useful for sequencing, analytics, or triggering other effects.
| Prop | Signature | Description |
|---|---|---|
onTextEngine | (ref: RefObject<TextEngineInstance>) => void | Called on mount with the instance ref. Use to get imperative control. |
onTextStart | (type, result, ctrl) => void | Fires when any spring starts animating. type is "line" | "lineWrap" | "word" | "wordWrap" | "letter" | "letterWrap". |
onTextChange | (type, result, ctrl) => void | Fires on every animation frame for each element. Use sparingly — can fire very frequently. |
onTextResolve | (type, result, ctrl) => void | Fires when an element's animation reaches its target value. |
onTextFullyPlayed | (type: "in" | "out") => void | Fires once after the entire sequence finishes (all elements in all layers). Perfect for chaining animations. |
<TextEngine
onTextFullyPlayed={(type) => {
if (type === 'in') startNextAnimation();
}}
onTextEngine={(ref) => {
engineRef.current = ref.current;
}}
...
/>Imperative Instance API
Access the instance via the onTextEngine callback or a ref. Essential for mode="manual".
interface TextEngineInstance {
mode: string; // reflects current mode prop
enabled: boolean; // reflects effective enabled state
lines: LineRef[][]; // DOM word refs grouped by line
words: string[][]; // all words as char arrays
letters: string[]; // all chars
playIn(): void; // trigger enter animation
playOut(): void; // trigger exit animation
togglePause(): void; // freeze / unfreeze animation
progress: RefObject<number>; // write 0–1 for progress-based manual control
}Manual mode example
import { useRef } from 'react';
import TextEngine, { type TextEngineInstance } from 'spring-text-engine';
export function ManualExample() {
const engineRef = useRef<TextEngineInstance | null>(null);
return (
<>
<button onClick={() => engineRef.current?.playIn()}>Play In</button>
<button onClick={() => engineRef.current?.playOut()}>Play Out</button>
<button onClick={() => engineRef.current?.togglePause()}>Pause</button>
<TextEngine
mode="manual"
tag="h1"
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 80, opacity: 0 }}
lineStagger={100}
overflow
onTextEngine={(ref) => { engineRef.current = ref.current; }}
>
Manual control
</TextEngine>
</>
);
}Progress-based manual control
// Write 0–1 to instance.progress.current each frame
// The engine polls it via an internal loop
useEffect(() => {
const onScroll = () => {
if (!engineRef.current?.progress) return;
const el = document.getElementById('section')!;
const { top, height } = el.getBoundingClientRect();
engineRef.current.progress.current =
Math.min(1, Math.max(0, 1 - top / (window.innerHeight - height)));
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
<TextEngine mode="manual" type="toggle" ... onTextEngine={...} />tengine factory
tengine is a Proxy-based factory returning pre-configured TextEngine for any HTML tag — useful when you want typed tags without passing the tag prop.
import { tengine } from 'spring-text-engine';
const H1 = tengine.h1;
const P = tengine.p;
<H1 lineIn={{ y: 0, opacity: 1 }} lineOut={{ y: 60, opacity: 0 }} lineStagger={100} overflow>
Heading
</H1>
<P wordIn={{ y: 0, opacity: 1 }} wordOut={{ y: 20, opacity: 0 }} wordStagger={40}>
Paragraph
</P>Using the Playground
The Playground → lets you configure all props visually and see the result instantly. All settings are encoded in the URL (?te=…) — copy the URL to share an exact configuration.
| Area | What it does |
|---|---|
| Left sidebar | All animation controls organized by layer (Line, Word, Letter). Each layer has In/Out targets, spring config, stagger/delay, and wrap settings. |
| Property toggles | Click a prop name (y, opacity, blur…) to enable it. A drag-slider appears to set the value. Colors: purple = transform, blue = filter, yellow = color. |
| Mode bar (bottom of preview) | Switch modes instantly without leaving the preview. Set a preview height to make it scrollable for testing always / forward modes. |
| Replay / Auto Replay | Manually replay or set an auto-replay interval. Auto-replay rerenders the text to restart the full animation cycle. |
| React Code tab | Auto-generated ready-to-paste React component code. Click Copy. |
| CSS tab | Write custom CSS injected live into the page. Use with the class name props from General settings. |
| Share link | Copies the current URL with the full encoded state. Anyone opening the link sees the same config. |