New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

spring-text-engine

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

spring-text-engine

Scroll-aware, spring-animated text component for React. Splits children into letter/word/line animation slots driven by react-spring.

latest
Source
npmnpm
Version
0.1.3
Version published
Maintainers
1
Created
Source

TextEngine

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

Table of contents

Installation / import

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';

Animation layers

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 }).

Modes

ModeBehaviour
"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).

Props reference

Core

PropTypeDefaultDescription
mode"always" | "once" | "forward" | "manual" | "progress""always"Animation trigger mode
enabledbooleantrueMaster enable switch
tagHtmlTags"span"HTML tag for the container element
columnGapnumber | "inherit"0.3Gap between words in em
overflowbooleanfalseSets overflow: hidden on wrapLine / wrapWord
rootMarginstring"0px"IntersectionObserver rootMargin (non-progress modes only). e.g. "-100px 0px"
childrenReactNodeText and/or React elements to animate

Progress / scroll trigger (mode="progress")

PropTypeDefaultDescription
type"toggle" | "interpolate""toggle"How scroll progress drives animation
interpolationStaggerCoefficientnumber0.3Spread of per-unit progress ranges in interpolate mode
triggerRefObject<HTMLElement>External element to use as scroll reference
startTriggerPos"top bottom"Scroll position where progress = 0
endTriggerPos"bottom top"Scroll position where progress = 1

Animation values

All default to {} (empty = layer not rendered).

PropDescription
wrapLineIn / wrapLineOutwrapLine enter / exit spring target
lineIn / lineOutLine enter / exit spring target
wrapWordIn / wrapWordOutwrapWord enter / exit spring target
wordIn / wordOutWord enter / exit spring target
wrapLetterIn / wrapLetterOutwrapLetter enter / exit spring target
letterIn / letterOutLetter enter / exit spring target

Spring configs

All optional SpringConfig objects. The shared config applies to both in and out; the directional overrides take precedence.

PropDescription
lineConfigLine spring config (in + out)
wordConfigWord spring config (in + out)
letterConfigLetter spring config (in + out)
lineConfigIn / lineConfigOutLine enter / exit spring config override
wordConfigIn / wordConfigOutWord enter / exit spring config override
letterConfigIn / letterConfigOutLetter enter / exit spring config override

Timing (all in ms)

PropDefaultDescription
delayIn0Global delay before the entire enter animation
delayOut0Global delay before the entire exit animation
lineDelayIn / lineDelayOut0Extra per-layer delay on top of global delay
wordDelayIn / wordDelayOut0
letterDelayIn / letterDelayOut0
lineStagger0Per-line stagger delay shared for in + out
wordStagger0Per-word stagger delay shared for in + out
letterStagger0Per-letter stagger delay shared for in + out
lineStaggerIn / lineStaggerOut0Override stagger for one direction
wordStaggerIn / wordStaggerOut0
letterStaggerIn / letterStaggerOut0

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.

Behaviour flags

PropDefaultDescription
immediateOuttrueExit animation is instant (no spring, no stagger). Set false for a full animated exit
enableInOutDelayesOnRerenderfalseApply delays when children change reactively. Default suppresses delays for instant swaps

SEO

PropDefaultDescription
seotrueRenders a visually-hidden plain-text copy so crawlers and screen readers see unsplit content

CSS class hooks

PropDescription
classNameContainer element
wrapLineClassNameEvery wrapLine span
lineClassNameEvery line span
wrapWordClassNameEvery wrapWord span
wordClassNameEvery word span
wrapLetterClassNameEvery wrapLetter span
letterClassNameEvery letter span

Callbacks

PropSignatureDescription
onTextEngine(ref: RefObject<TextEngineInstance>) => voidCalled on mount with the instance ref
onTextStartTextEngineHandlerTypeFires when any spring starts animating
onTextChangeTextEngineHandlerTypeFires on every spring frame
onTextResolveTextEngineHandlerTypeFires when any spring settles
onTextFullyPlayed(type: "in" | "out") => voidFires once after the full sequence finishes

Examples

1. Line-by-line reveal

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>
  );
}

2. Word-by-word fade up

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>
  );
}

3. Letter-by-letter cascade

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>
  );
}

4. Mixed children with inline styling

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.

5. Once mode — plays once on first view

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>
  );
}

6. Forward mode — only plays on downward scroll

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>
  );
}

7. Manual mode — imperative control

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>
    </>
  );
}

8. Manual mode with progress

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>
  );
}

9. Progress mode — scroll-driven

mode="progress" wires the animation directly to scroll position between start and end. No manual scroll handling needed.

Toggle sub-mode

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>
  );
}

Interpolate sub-mode

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>
  );
}

10. Progress mode with GSAP-style offsets

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>
  );
}

11. rootMargin — offset the viewport trigger

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>
  );
}

12. Factory pattern — tengine

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>
    </>
  );
}

TriggerPos format

Used by start and end props (and ProgressTrigger component directly).

"<element-edge> <viewport-edge>"
"<element-edge> <viewport-edge>+=<px>"
"<element-edge> <viewport-edge>-=<px>"
  • element-edge: top | center | bottom — edge of the target element
  • viewport-edge: top | center | bottom — edge of the viewport
  • offset (optional): +=200 adds 200 px, -=100 subtracts 100 px
ExampleMeaning
"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

Imperative instance API

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', ...]

Keywords

react

FAQs

Package last updated on 25 Mar 2026

Did you know?

Socket

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.

Install

Related posts