Skip to content

Latest commit

 

History

History
265 lines (193 loc) · 14 KB

README.md

File metadata and controls

265 lines (193 loc) · 14 KB

@react-native-motion-kit/text-motion

High-performance split text animations for React Native, powered by Reanimated and extensible renderers.

Korean documentation: README.ko.md

Package documentation: packages/text-motion/README.md

This repository is a pnpm workspace for the @react-native-motion-kit/text-motion package and Expo example app.

Package

text-motion is not a single prop-heavy wrapper like <AnimatedText animation="fadeIn" />. It helps you create reusable animated text components by choosing how the text is split, when each visible token starts, and which effects are applied.

The package exposes that as a recipe pipeline:

split -> layout(renderer) -> timeline -> effect -> motion/accessibility -> component

Use presets when you want a good default quickly. Compose a recipe when you need to control tokenization, timing, effects, accessibility, or renderer choice.

For TypeScript users, .component() is only available after a renderer is selected with .layout(...). .recipe() can still be used before layout when you need to inspect or pass around an unfinished recipe.

Quick preset usage:

import { editorialRise } from '@react-native-motion-kit/text-motion/presets';

const HeroReveal = editorialRise().component();

export function HeroTitle() {
  return <HeroReveal>Design motion that feels native</HeroReveal>;
}

Custom recipe usage:

import {
  defineTextMotion,
  fade,
  nativeText,
  rise,
  stagger,
  words,
} from '@react-native-motion-kit/text-motion';

const WordReveal = defineTextMotion()
  .split(words())
  .layout(nativeText())
  .timeline(stagger(0.034))
  .effect(fade().and(rise({ y: 10 })))
  .component();

Playback controls are useful when an event should tell a text motion component to play, replay, reset, or stop without remounting. In that mode the app owns the event, while the component still owns playback execution and uses its recipe .motion() config.

Basic controls usage:

import {
  defineTextMotion,
  fade,
  nativeText,
  rise,
  stagger,
  useTextMotionControls,
  words,
} from '@react-native-motion-kit/text-motion';
import { Button } from 'react-native';

const ReplayableReveal = defineTextMotion()
  .split(words())
  .layout(nativeText())
  .timeline(stagger(0.04))
  .effect(fade().and(rise({ y: 12 })))
  .motion({ kind: 'timing', options: { duration: 420 } })
  .component();

export function Headline() {
  const controls = useTextMotionControls();

  return (
    <>
      <ReplayableReveal controls={controls}>
        Replay without remounting
      </ReplayableReveal>
      <Button title="Replay" onPress={controls.replay} />
    </>
  );
}

controls is a command channel, not a progress value. Use it for buttons, screen focus, onboarding steps, example replay controls, and coordinated title/subtitle replay.

Performance note: controls are intended for short UI text such as titles, labels, and product copy. If you plan to animate long paragraphs, grapheme-split copy, or many rows at once, measure in your target app before treating that as a supported workload.

Controlled progress is useful when text motion should follow a raw value outside the component, such as scroll position, gesture progress, or another Reanimated shared value. In that mode the app owns the exact progress and the text component only renders the current state.

Basic controlled progress usage:

import {
  defineTextMotion,
  fade,
  nativeText,
  rise,
  stagger,
  words,
} from '@react-native-motion-kit/text-motion';
import { Button } from 'react-native';
import { useSharedValue, withTiming } from 'react-native-reanimated';

const ControlledReveal = defineTextMotion()
  .split(words())
  .layout(nativeText())
  .timeline(stagger(0.08))
  .effect(fade().and(rise({ y: 12 })))
  .component();

export function Headline() {
  const progress = useSharedValue(0);

  return (
    <>
      <ControlledReveal progress={progress}>
        Progress drives the whole reveal
      </ControlledReveal>
      <Button title="Play" onPress={() => (progress.value = withTiming(1))} />
    </>
  );
}

progress is normalized whole-text progress: 0 is initial and 1 is final. Timeline delays still map that global value to each token's local progress, so stagger() and wave() keep their timing shape. This is handy when a headline should reveal as a user scrolls a card into view, when text should follow a gesture, or when several UI elements need to share one progress value.

When progress is provided, .motion() does not run internal autoplay. Choose the playback feel where you update the shared value instead, for example progress.value = withTiming(1, { duration: 720 }) or progress.value = withSpring(1). controls and progress cannot be used together.

Line reveal is for motion based on the lines React Native actually rendered after width, font, font scale, alignment, and language shaping are applied. Use overlayText() with lineReveal() and do not add .split(...):

import {
  defineTextMotion,
  lineReveal,
  overlayText,
  stagger,
} from '@react-native-motion-kit/text-motion';

const HeroLines = defineTextMotion()
  .layout(overlayText())
  .timeline(stagger(0.08))
  .effect(lineReveal({ y: 18 }))
  .motion({ kind: 'timing', options: { duration: 420 } })
  .component();

Use this for responsive hero titles, section headings, and concise product copy where the visual line breaks matter. nativeText() animates source word or grapheme tokens. overlayText() animates actual rendered lines. lines() is still only an explicit \n splitter; it does not discover automatic wrapping. TypeScript and runtime capability checks reject .split(...) with overlayText().

overlayText() also accepts the built-in style-transform effects (fade, rise, slide, shake, scale, and pulse) because they operate on the rendered line frame. lineReveal() is split internally: its opacity is applied to the line frame, while its vertical reveal offset moves only the paragraph copy inside the line mask. This lets combinations such as lineReveal().and(scale()) and pulse() grow from the measured line center without being clipped by the renderer's own line mask.

Stable MVP Scope

  • recipe API: defineTextMotion().split().layout().timeline().effect().component()
  • splitters: graphemes, words, custom, experimental newline-only lines
  • renderers: nativeText, overlayText
  • timelines: stagger, sequence, parallel, wave
  • effects: fade, rise, slide, scale, pulse, shake, lineReveal
  • presets subpath: @react-native-motion-kit/text-motion/presets
  • explicit playback controls for event-driven play/replay/reset/stop
  • controlled progress via external Reanimated shared values
  • accessibility default: parent label plus hidden decorative token nodes

nativeText() is a transform-first token renderer for titles, labels, onboarding copy, and short product copy. Animated tokens use an animated View container with an inner Text so transforms behave consistently across React Native platforms. Static tokens still render as plain Text. This makes nativeText() useful for source word or grapheme motion, but it is not a full React Native Text drop-in. Layout props such as numberOfLines, ellipsizeMode, and onTextLayout are intentionally outside the stable MVP contract.

When you pass nativeText({ testIDPrefix }), generated token testID values identify the animated token container. Text rendering props such as allowFontScaling and maxFontSizeMultiplier are forwarded to the inner Text. Treat this as testing and compatibility guidance, not as a styling extension API. Very long grapheme-by-grapheme animations create many native views and should be treated as stress cases until a dedicated performance checkpoint says otherwise.

The example app includes Playback -> Renderer Performance for this checkpoint. Use it when deciding whether a workload is normal, caution, or stress for your target devices. A threshold should be called measured only after the same case family has iOS and Android evidence. One-platform, simulator-only, emulator-only, or dev-mode-only checks should be treated as provisional; untested cases stay unknown.

Manual hard line breaks are supported: text such as "First line\nSecond line" keeps the newline as a visual break in nativeText(), and the newline does not consume a motion index. Use overlayText() when the motion unit should be the actual rendered line after React Native wraps the paragraph.

overlayText() preserves blank or whitespace-only rendered lines visually, but those lines do not consume a motion index. It uses one full paragraph copy per moving rendered line, so work scales with visible rendered lines rather than source words or graphemes. This is intended for titles, headings, and product copy. Do not treat performance as measured until the same case family has iOS and Android evidence.

By default, playback is lifecycle-driven: animated tokens or lines autoplay on mount, ordinary parent rerenders with the same text/recipe preserve in-flight progress, and text/effect/timeline/motion changes replay the affected animation. Text changes are enter-only in the MVP: old text is not kept for exit animation, crossfade, or token diffing. Components also accept controls for event-driven playback and progress, a Reanimated SharedValue<number> from 0 to 1, for raw externally controlled progress. controls uses the component recipe .motion() against the current text; progress is app-owned and makes changed text follow the current shared value instead of starting autoplay.

For overlayText(), identical line measurement does not replay. Geometry-only relayout preserves the current run. If line topology changes, component-owned playback restarts once for the new rendered lines. With external progress, the app-owned shared value is preserved and applied to the new geometry. Reduced motion renders the final readable source text without measurement or playback overlays. Malformed or unsupported native geometry also falls back to readable final source text.

Before the first valid line measurement, the source paragraph remains the layout owner. If an effect starts at scale: 1, the pending source keeps the same combined initial opacity and transform as other renderers. If the initial scale is not exactly 1, the pending source is visually hidden until valid line geometry is available so the first visible scaled frame uses the measured line center instead of the paragraph center.

This fixes renderer self-clipping for line-level scale and pulse effects. It does not reserve extra layout space for large transforms and it cannot bypass clipping from an ancestor with overflow: hidden; those remain normal React Native transform constraints.

Skia, rich text, and RN-rendered line-to-token mapping are deferred. Context/provider playback wiring and public playback refs are intentionally not part of the design; pass controls explicitly where a component should respond to playback commands.

Scope & Roadmap

text-motion stays focused on split text motion: tokenization, layout-aware timing, effects, renderers, split-token playback, accessibility, and presets for titles, labels, onboarding copy, and product copy.

Features that depend on text tokens or text layout can be considered here, including typewriter, scramble, wipe, text-change transitions, future rendered-line extensions such as masks or word-in-line effects, and renderer-specific effects behind explicit capability boundaries.

Features that solve a different problem are likely better served by separate package candidates. Number count-up, odometer, currency, percentage, timer, and delta animations are value-formatting problems rather than split-text layout problems. A separate value-motion package could share Motion Kit conventions without expanding the text-motion API surface.

Decision rule: add a feature to text-motion only when it improves text-specific production work without making the common recipe API harder to understand.

Install

pnpm add @react-native-motion-kit/text-motion react-native-reanimated react-native-worklets

The MVP peer dependency minimums are react-native-reanimated@>=4.0.0 and react-native-worklets@>=0.5.0. Follow the official Reanimated setup for New Architecture and Worklets configuration.

Workspace

react-native-text-motion/
  example/
  packages/
    text-motion/

Development

corepack pnpm install
corepack pnpm run typecheck
corepack pnpm run test
corepack pnpm run build
corepack pnpm run example:build

Useful package-level docs:

Should You Use It?

Use this package today when you need:

  • entrance motion for short React Native text such as titles, labels, onboarding copy, or product copy
  • word-level or grapheme-level reveal effects powered by Reanimated
  • reusable motion recipes instead of one-off animation props
  • accessible split text that screen readers read as one phrase
  • event-driven playback controls for replay/reset/stop without remounting
  • externally controlled raw progress for scroll, gesture, or synchronized text motion
  • rendered-line reveal for responsive hero titles, headings, and concise product copy
  • a lightweight core package without Skia as a required dependency

Wait for a later version if you need:

  • playback APIs such as pause, seek, or reverse
  • first-class scroll, gesture, or in-view drivers
  • per-token line mapping or mixed word-in-line animation
  • Skia effects such as blur, glow, shaders, masks, or glyph distortion
  • rich nested text with links, selectable text, or full native Text layout parity
  • heavy use across long paragraphs, virtualized lists, or dense UI without your own performance validation

Next focus: verifying line renderer performance and platform behavior, then deciding whether state-transition props, pause/seek/reverse, in-view helpers, scroll/gesture drivers, word-in-line effects, or optional Skia renderers deserve first-class APIs.

License

MIT