
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
spring-text-engine
Advanced tools
Scroll-aware, spring-animated text component for React. Splits children into letter/word/line animation slots driven by react-spring.
A scroll-aware, spring-animated text component built on react-spring.
Splits children into letter / word / line animation slots and drives them with independent springs. Mixed children are fully supported — plain strings animate alongside React elements (<span>, <strong>, etc.), and non-text elements (SVGs, icons, components) are treated as a single animated word unit.
Documentation & Playground → · GitHub
npm install spring-text-engine
# or
yarn add spring-text-engine
# or
pnpm add spring-text-engine
@react-spring/web is a required peer dependency:
npm install @react-spring/web
import TextEngine from 'spring-text-engine';
import type { TextEngineInstance, EngineProps } from 'spring-text-engine';
// or named imports
import { TextEngine, ProgressTrigger, tengine } from 'spring-text-engine';
Each word is wrapped in up to 3 nested layers. Layers are only rendered when their corresponding *In prop is non-empty, keeping the DOM flat when a layer is not needed.
<wrapLine> ← overflow clip + line-level spring
<line> ← line-staggered spring (all words on same line share the same delay)
<wrapWord> ← overflow clip + word spring
<word> ← word-level spring
<wrapLetter> ← per-letter overflow clip
<letter> ← per-letter spring
</wrapLetter>
</word>
</wrapWord>
</line>
</wrapLine>
Each layer has an In target (enter state) and an Out target (exit state). Set the out state to the resting position (e.g. { y: 100, opacity: 0 }) and the in state to the destination (e.g. { y: 0, opacity: 1 }).
| Mode | Behaviour |
|---|---|
"always" | Plays in when the element enters the viewport; plays out when it leaves. Repeats. (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 on upward scroll back into view. |
"manual" | No automatic trigger. Control via instance.playIn(), instance.playOut(), instance.togglePause(), or by writing to instance.progress.current (0–1). |
"progress" | Animation is driven by scroll progress between start and end positions using ProgressTrigger internally. Sub-modes: type="toggle" (snap) or type="interpolate" (smooth). |
| Prop | Type | Default | Description |
|---|---|---|---|
mode | "always" | "once" | "forward" | "manual" | "progress" | "always" | Animation trigger mode |
enabled | boolean | true | Master enable switch |
tag | HtmlTags | "span" | HTML tag for the container element |
columnGap | number | "inherit" | 0.3 | Gap between words in em |
overflow | boolean | false | Sets overflow: hidden on wrapLine / wrapWord |
rootMargin | string | "0px" | IntersectionObserver rootMargin (non-progress modes only). e.g. "-100px 0px" |
children | ReactNode | — | Text and/or React elements to animate |
mode="progress")| Prop | Type | Default | Description |
|---|---|---|---|
type | "toggle" | "interpolate" | "toggle" | How scroll progress drives animation |
interpolationStaggerCoefficient | number | 0.3 | Spread of per-unit progress ranges in interpolate mode |
trigger | RefObject<HTMLElement> | — | External element to use as scroll reference |
start | TriggerPos | "top bottom" | Scroll position where progress = 0 |
end | TriggerPos | "bottom top" | Scroll position where progress = 1 |
All default to {} (empty = layer not rendered).
| Prop | Description |
|---|---|
wrapLineIn / wrapLineOut | wrapLine enter / exit spring target |
lineIn / lineOut | Line enter / exit spring target |
wrapWordIn / wrapWordOut | wrapWord enter / exit spring target |
wordIn / wordOut | Word enter / exit spring target |
wrapLetterIn / wrapLetterOut | wrapLetter enter / exit spring target |
letterIn / letterOut | Letter enter / exit spring target |
All optional SpringConfig objects. The shared config applies to both in and out; the directional overrides take precedence.
| Prop | Description |
|---|---|
lineConfig | Line spring config (in + out) |
wordConfig | Word spring config (in + out) |
letterConfig | Letter spring config (in + out) |
lineConfigIn / lineConfigOut | Line enter / exit spring config override |
wordConfigIn / wordConfigOut | Word enter / exit spring config override |
letterConfigIn / letterConfigOut | Letter enter / exit spring config override |
| Prop | Default | Description |
|---|---|---|
delayIn | 0 | Global delay before the entire enter animation |
delayOut | 0 | Global delay before the entire exit animation |
lineDelayIn / lineDelayOut | 0 | Extra per-layer delay on top of global delay |
wordDelayIn / wordDelayOut | 0 | |
letterDelayIn / letterDelayOut | 0 | |
lineStagger | 0 | Per-line stagger delay shared for in + out |
wordStagger | 0 | Per-word stagger delay shared for in + out |
letterStagger | 0 | Per-letter stagger delay shared for in + out |
lineStaggerIn / lineStaggerOut | 0 | Override stagger for one direction |
wordStaggerIn / wordStaggerOut | 0 | |
letterStaggerIn / letterStaggerOut | 0 |
Line stagger is based on the line index (all words on the same line get the same delay). Word and letter stagger are based on their global sequential index.
| Prop | Default | Description |
|---|---|---|
immediateOut | true | Exit animation is instant (no spring, no stagger). Set false for a full animated exit |
enableInOutDelayesOnRerender | false | Apply delays when children change reactively. Default suppresses delays for instant swaps |
| Prop | Default | Description |
|---|---|---|
seo | true | Renders a visually-hidden plain-text copy so crawlers and screen readers see unsplit content |
| Prop | Description |
|---|---|
className | Container element |
wrapLineClassName | Every wrapLine span |
lineClassName | Every line span |
wrapWordClassName | Every wrapWord span |
wordClassName | Every word span |
wrapLetterClassName | Every wrapLetter span |
letterClassName | Every letter span |
| Prop | Signature | Description |
|---|---|---|
onTextEngine | (ref: RefObject<TextEngineInstance>) => void | Called on mount with the instance ref |
onTextStart | TextEngineHandlerType | Fires when any spring starts animating |
onTextChange | TextEngineHandlerType | Fires on every spring frame |
onTextResolve | TextEngineHandlerType | Fires when any spring settles |
onTextFullyPlayed | (type: "in" | "out") => void | Fires once after the full sequence finishes |
Each line slides up from below and fades in. Lines stagger by 100 ms. The overflow flag clips the text so the slide starts hidden.
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function Hero() {
return (
<TextEngine
tag="h1"
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: '100%', opacity: 0 }}
lineStagger={100}
lineConfig={{ duration: 900, easing: easings.easeOutCubic }}
overflow
>
The quick brown fox
</TextEngine>
);
}
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function Subtitle() {
return (
<TextEngine
tag="p"
wordIn={{ y: 0, opacity: 1 }}
wordOut={{ y: 40, opacity: 0 }}
wordStagger={60}
wordConfig={{ duration: 700, easing: easings.easeOutQuart }}
>
Animate every word independently
</TextEngine>
);
}
import { config } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function Title() {
return (
<TextEngine
tag="h2"
letterIn={{ y: 0, opacity: 1, scale: 1 }}
letterOut={{ y: 20, opacity: 0, scale: 0.8 }}
letterStagger={30}
letterConfig={config.gentle}
>
Hello world
</TextEngine>
);
}
Plain text and styled <span> elements animate together. Words inside the span are animated individually while the span's style and className props are preserved on each word.
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function Headline() {
return (
<TextEngine
tag="h1"
letterIn={{ y: 0, opacity: 1 }}
letterOut={{ y: 30, opacity: 0 }}
letterStagger={25}
letterConfig={{ duration: 600, easing: easings.easeOutExpo }}
>
Hello{' '}
<span style={{ color: 'red' }}>world</span>
{' '}this is{' '}
<span style={{ color: 'blue' }}>
cool <span style={{ fontWeight: 700 }}>stuff</span>
</span>
</TextEngine>
);
}
Non-text children (SVGs, icons) are treated as a single word unit and share the word-level spring.
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function SectionTitle() {
return (
<TextEngine
tag="h2"
mode="once"
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 60, opacity: 0 }}
lineStagger={120}
lineConfig={{ duration: 1000, easing: easings.easeOutCubic }}
overflow
>
Plays in exactly once
</TextEngine>
);
}
The animation plays in when the user scrolls down to the element. If they scroll back up and then down again, it does not replay.
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function Paragraph() {
return (
<TextEngine
tag="p"
mode="forward"
wordIn={{ y: 0, opacity: 1 }}
wordOut={{ y: 20, opacity: 0 }}
wordStagger={40}
wordConfig={{ duration: 600, easing: easings.easeOutQuart }}
>
Only animates in on forward scroll
</TextEngine>
);
}
Control playback entirely from the parent via a ref.
import { useRef } from 'react';
import { easings } from '@react-spring/web';
import TextEngine, { type TextEngineInstance } from 'spring-text-engine';
export function ManualExample() {
const engineRef = useRef<TextEngineInstance | null>(null);
return (
<>
<TextEngine
ref={engineRef}
mode="manual"
tag="h1"
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 80, opacity: 0 }}
lineStagger={100}
lineConfig={{ duration: 1000, easing: easings.easeOutCubic }}
overflow
onTextEngine={(ref) => { engineRef.current = ref.current; }}
>
Manual control
</TextEngine>
<button onClick={() => engineRef.current?.playIn()}>Play In</button>
<button onClick={() => engineRef.current?.playOut()}>Play Out</button>
<button onClick={() => engineRef.current?.togglePause()}>Pause</button>
</>
);
}
Write a 0–1 value to instance.progress.current on each animation frame. The engine polls it via an internal loop and drives the springs accordingly.
import { useRef, useEffect } from 'react';
import TextEngine, { type TextEngineInstance } from 'spring-text-engine';
export function ScrollDrivenManual() {
const engineRef = useRef<TextEngineInstance | null>(null);
useEffect(() => {
const onScroll = () => {
const el = document.getElementById('section');
if (!el || !engineRef.current?.progress) return;
const { top, height } = el.getBoundingClientRect();
const p = Math.min(1, Math.max(0, 1 - top / (window.innerHeight - height)));
engineRef.current.progress.current = p;
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<TextEngine
ref={engineRef}
mode="manual"
type="toggle"
tag="p"
wordIn={{ y: 0, opacity: 1 }}
wordOut={{ y: 30, opacity: 0 }}
wordStagger={50}
onTextEngine={(ref) => { engineRef.current = ref.current; }}
>
Driven by custom scroll logic
</TextEngine>
);
}
mode="progress" wires the animation directly to scroll position between start and end. No manual scroll handling needed.
Each word snaps to its in or out state as the scroll position crosses its stagger threshold.
import TextEngine from 'spring-text-engine';
export function ToggleProgress() {
return (
<TextEngine
tag="p"
mode="progress"
type="toggle"
start="top bottom"
end="bottom top"
wordIn={{ y: 0, opacity: 1 }}
wordOut={{ y: 40, opacity: 0 }}
wordStagger={60}
>
Words snap in as you scroll
</TextEngine>
);
}
Each word smoothly interpolates between in and out as scroll progresses. The interpolationStaggerCoefficient controls how staggered the per-word progress windows are.
import TextEngine from 'spring-text-engine';
export function InterpolateProgress() {
return (
<TextEngine
tag="p"
mode="progress"
type="interpolate"
interpolationStaggerCoefficient={0.2}
start="top 80%"
end="bottom 20%"
letterIn={{ y: 0, opacity: 1 }}
letterOut={{ y: 20, opacity: 0 }}
>
Letters interpolate smoothly with scroll
</TextEngine>
);
}
Trigger positions support pixel offsets using += and -= syntax. The first word is the element edge (top/center/bottom), the second is the viewport edge, and the optional suffix shifts the trigger point.
+=N — trigger fires N px later in the scroll direction.
-=N — trigger fires N px earlier.
import TextEngine from 'spring-text-engine';
export function OffsetProgress() {
return (
<TextEngine
tag="h2"
mode="progress"
type="toggle"
// start 200px before the element's top hits the viewport bottom
start="top bottom+=200"
// end 100px after the element's bottom passes the viewport top
end="bottom top-=100"
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 60, opacity: 0 }}
lineStagger={80}
>
Offset trigger points
</TextEngine>
);
}
You can also use an external element as the scroll reference:
import { useRef } from 'react';
import TextEngine from 'spring-text-engine';
export function ExternalTrigger() {
const sectionRef = useRef<HTMLDivElement>(null);
return (
<div ref={sectionRef} style={{ height: '300vh' }}>
<TextEngine
mode="progress"
type="toggle"
trigger={sectionRef}
start="top bottom"
end="bottom top"
wordIn={{ opacity: 1, y: 0 }}
wordOut={{ opacity: 0, y: 30 }}
wordStagger={40}
>
Triggered by the parent section
</TextEngine>
</div>
);
}
In non-progress modes (always, once, forward, manual) the IntersectionObserver rootMargin shifts when the element is considered "in view". Negative values trigger the animation later (the element must be further inside the viewport).
import { easings } from '@react-spring/web';
import TextEngine from 'spring-text-engine';
export function LateEntrance() {
return (
<TextEngine
tag="p"
mode="always"
// only triggers when the element is at least 150px inside the viewport
rootMargin="-150px 0px"
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 50, opacity: 0 }}
lineStagger={80}
lineConfig={{ duration: 800, easing: easings.easeOutCubic }}
overflow
>
Animates only when well inside the viewport
</TextEngine>
);
}
tengine is a Proxy-based factory that returns a pre-configured TextEngine for any HTML tag. Useful when you want a typed tag without passing the tag prop.
import { tengine } from 'spring-text-engine';
import { easings } from '@react-spring/web';
const H1 = tengine.h1;
const P = tengine.p;
export function FactoryExample() {
return (
<>
<H1
lineIn={{ y: 0, opacity: 1 }}
lineOut={{ y: 80, opacity: 0 }}
lineStagger={100}
lineConfig={{ duration: 1000, easing: easings.easeOutCubic }}
overflow
>
Heading with line animation
</H1>
<P
wordIn={{ y: 0, opacity: 1 }}
wordOut={{ y: 20, opacity: 0 }}
wordStagger={40}
wordConfig={{ duration: 600, easing: easings.easeOutQuart }}
>
Paragraph with word animation
</P>
</>
);
}
Used by start and end props (and ProgressTrigger component directly).
"<element-edge> <viewport-edge>"
"<element-edge> <viewport-edge>+=<px>"
"<element-edge> <viewport-edge>-=<px>"
top | center | bottom — edge of the target elementtop | center | bottom — edge of the viewport+=200 adds 200 px, -=100 subtracts 100 px| Example | Meaning |
|---|---|
"top bottom" | Progress = 0 when element top reaches viewport bottom |
"bottom top" | Progress = 1 when element bottom reaches viewport top |
"top bottom+=200" | Progress = 0 starts 200 px after element top would normally hit viewport bottom |
"center center" | Triggers when element center aligns with viewport center |
"bottom top-=100" | Progress = 1 fires 100 px before element bottom hits viewport top |
Accessed via ref or the onTextEngine callback.
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 (manual mode)
playOut(): void; // trigger exit animation (manual mode)
togglePause(): void; // freeze / unfreeze animation
progress: RefObject<number>; // write 0–1 for progress-based manual control
}
const ref = useRef<TextEngineInstance>(null);
// Trigger playback
ref.current?.playIn();
ref.current?.playOut();
// Progress-based control (manual mode)
ref.current!.progress!.current = 0.5;
// Read layout data
console.log(ref.current?.lines); // [[{ word, index, lineIndex }, ...], ...]
console.log(ref.current?.letters); // ['H','e','l','l','o', ...]
FAQs
Scroll-aware, spring-animated text component for React. Splits children into letter/word/line animation slots driven by react-spring.
We found that spring-text-engine demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.