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>
Set the 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.
Wrap layers (wrapLine, wrapWord, wrapLetter) share their spring config and stagger with their corresponding content layer. wrapWord uses wordConfig / wordStagger, etc.

Modes

mode

The mode prop controls when animation plays. Default is "always".

ModeBehavior
alwaysPlays in when entering viewport, plays out when leaving. Repeats every time. (default)
oncePlays in the first time the element enters the viewport. Never replays.
forwardPlays in on downward scroll into view. Does not replay if the user scrolls back up and down again.
manualNo automatic trigger. Control via instance.playIn(), instance.playOut(), instance.togglePause(), or by writing 0–1 to instance.progress.current.
progressAnimation 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.

PropUnit / TypeDescription
ypx (number)Vertical translation. Positive = down, negative = up. Compiles to translateY().
xpx (number)Horizontal translation. Compiles to translateX().
opacity0–1CSS opacity.
scalenumberCSS scale(). 0 = invisible, 1 = normal, 2 = double.
rotatedeg (number)Rotation around Z axis. Compiles to rotateZ().
rotateXdeg (number)3D rotation around X axis. Use with a parent perspective for best results.
rotateYdeg (number)3D rotation around Y axis.
skewXdeg (number)Skew on X axis.
skewYdeg (number)Skew on Y axis.
filterCSS stringCSS filter string. React-spring interpolates numeric values inside. E.g. 'blur(0px)' ↔ 'blur(16px)'. Both In and Out must use the same filter function.
colorhex / rgbCSS 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).
All six layer pairs accept the same animatable values: 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.

PropApplies toDescription
lineConfigline + wrapLine in & outShared spring config for line layer.
lineConfigInline + wrapLine enter onlyOverrides lineConfig for enter animation.
lineConfigOutline + wrapLine exit onlyOverrides lineConfig for exit animation.
wordConfigword + wrapWord in & outShared spring config for word layer.
wordConfigIn / wordConfigOutword + wrapWordPer-direction word configs.
letterConfigletter + wrapLetter in & outShared spring config for letter layer.
letterConfigIn / letterConfigOutletter + wrapLetterPer-direction letter configs.

SpringConfig properties

PropertyType / DefaultDescription
tensionnumber / 170Spring stiffness. Higher = faster and snappier. Used in spring mode.
frictionnumber / 26Spring damping. Lower = more bounce / oscillation. Used in spring mode.
massnumber / 1Simulated mass. Higher = slower, heavier feel.
durationmsFixed duration in milliseconds. When set, switches from physics to time-based animation.
easingeasings.* functionEasing 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

PropDefaultDescription
delayIn0Global delay before the entire enter animation starts.
delayOut0Global delay before the entire exit animation starts.
lineDelayIn / lineDelayOut0Extra per-layer delay added on top of the global delay.
wordDelayIn / wordDelayOut0
letterDelayIn / letterDelayOut0

Stagger

PropDefaultDescription
lineStagger0Per-line delay offset. All words on the same line share the same line index.
lineStaggerIn / lineStaggerOut0Override stagger for one direction only.
wordStagger0Per-word delay offset. Words are indexed sequentially across the whole text.
wordStaggerIn / wordStaggerOut0
letterStagger0Per-letter delay offset. Letters are indexed sequentially across the whole text.
letterStaggerIn / letterStaggerOut0

Behaviour flags

PropDefaultDescription
immediateOuttrueExit animation is instant — no spring, no stagger. Set false for a full animated exit.
enableInOutDelayesOnRerenderfalseApply 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 pairConfig shared withWhen renders
wrapLineIn / wrapLineOutlineConfig`, `lineStaggerWhen wrapLineIn is non-empty
wrapWordIn / wrapWordOutwordConfig`, `wordStaggerWhen wrapWordIn is non-empty
wrapLetterIn / wrapLetterOutletterConfig`, `letterStaggerWhen 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.

PropType / DefaultDescription
type"toggle" | "interpolate" / "toggle"toggle: each unit snaps in/out as scroll crosses its stagger threshold. interpolate: each unit smoothly interpolates with scroll position.
startTriggerPos / "top bottom"Scroll position where progress = 0.
endTriggerPos / "bottom top"Scroll position where progress = 1.
interpolationStaggerCoefficientnumber / 0.3In interpolate mode: spreads out per-unit progress windows. 0 = all animate at once. Higher = each unit animates later than the previous.
triggerRefObject<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.

ExampleMeaning
"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".

PropDefaultDescription
seotrueRenders 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.
The SEO copy strips non-text children (images, SVGs, icons) so only the readable text is duplicated. Users select text from the visible animated layer; the hidden copy is non-interactive.

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

PropType / DefaultDescription
tagHtmlTag / "span"HTML tag for the container element. Choose semantically (h1, p, span…).
enabledboolean / trueMaster toggle. When false, text renders static in the In state.
overflowboolean / falseSets overflow:hidden on wrapLine and wrapWord containers.
columnGapnumber | "inherit" / 0.3Gap between words in em units.
rootMarginstring / "0px"IntersectionObserver rootMargin for non-progress modes. E.g. "-100px 0px" triggers later.

Class name props

PropApplied to
classNameRoot container element
wrapLineClassNameEvery wrapLine span
lineClassNameEvery animated line span
wrapWordClassNameEvery wrapWord span
wordClassNameEvery animated word span
wrapLetterClassNameEvery wrapLetter span
letterClassNameEvery animated letter span

Callbacks

Lifecycle callbacks fired at different stages. Useful for sequencing, analytics, or triggering other effects.

PropSignatureDescription
onTextEngine(ref: RefObject<TextEngineInstance>) => voidCalled on mount with the instance ref. Use to get imperative control.
onTextStart(type, result, ctrl) => voidFires when any spring starts animating. type is "line" | "lineWrap" | "word" | "wordWrap" | "letter" | "letterWrap".
onTextChange(type, result, ctrl) => voidFires on every animation frame for each element. Use sparingly — can fire very frequently.
onTextResolve(type, result, ctrl) => voidFires when an element's animation reaches its target value.
onTextFullyPlayed(type: "in" | "out") => voidFires 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.

AreaWhat it does
Left sidebarAll animation controls organized by layer (Line, Word, Letter). Each layer has In/Out targets, spring config, stagger/delay, and wrap settings.
Property togglesClick 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 ReplayManually replay or set an auto-replay interval. Auto-replay rerenders the text to restart the full animation cycle.
React Code tabAuto-generated ready-to-paste React component code. Click Copy.
CSS tabWrite custom CSS injected live into the page. Use with the class name props from General settings.
Share linkCopies the current URL with the full encoded state. Anyone opening the link sees the same config.