Text FXAdvanced Text Animationsno dependencies

ScatterText

Letters start scattered at seeded random positions, rotations, and scales and fly together into the headline with eased, staggered motion; on hover they burst apart again and reassemble when the pointer leaves.

Preview

Pieces fall into place

Customize

8 props
240
1.1
0.025
56

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add scatter-text

Writes components/ui/scatter-text.tsx and components/styles/scatter-text.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { ScatterText } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ScatterText />6  );7}

Props

PropTypeDefault
text

Text whose letters assemble from scattered positions.

string'Pieces fall into place'
spread

Maximum scatter distance in pixels.

number240
duration

Duration of each letter's flight in seconds.

number1.1
ease

GSAP-style ease name (sampled into a CSS linear() easing).

"power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out"'expo.out'
stagger

Delay between consecutive letters in seconds.

number0.025
staggerFrom

Where the stagger wave starts from.

"start" | "center" | "end" | "edges" | "random"'center'
scatterOnHover

Scatter the letters again while hovered and reassemble on leave.

booleantrue
seed

Seed for the deterministic scatter layout.

number7
color

Text color (defaults to the theme foreground).

stringundefined
fontSize

Font size in pixels.

number56
as

Element rendered as the root.

"h1" | "h2" | "h3" | "h4" | "p" | "div"'h2'
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

Accessibility

The text is read once from a visually hidden span; the scattered letters are aria-hidden. Scatter positions come from a seeded PRNG so server and client markup match. The hover scatter is decorative only. Under prefers-reduced-motion the letters render assembled with no transitions and hover does nothing.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/scatter-text.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/scatter-text.module.css";5 6export type ScatterTextEase =7  "power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out";8 9export type ScatterTextStaggerFrom = "start" | "center" | "end" | "edges" | "random";10 11export interface ScatterTextProps {12  /** Text whose letters assemble from scattered positions */13  text?: string;14  /** Maximum scatter distance in pixels */15  spread?: number;16  /** Duration of each letter's flight in seconds */17  duration?: number;18  /** GSAP-style ease name */19  ease?: ScatterTextEase;20  /** Delay between consecutive letters in seconds */21  stagger?: number;22  /** Where the stagger wave starts from */23  staggerFrom?: ScatterTextStaggerFrom;24  /** Scatter the letters again while hovered and reassemble on leave */25  scatterOnHover?: boolean;26  /** Seed for the deterministic scatter layout */27  seed?: number;28  /** Text color (defaults to the theme foreground) */29  color?: string;30  /** Font size in pixels */31  fontSize?: number;32  /** Element rendered as the root */33  as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";34  /** Optional className merged onto the root */35  className?: string;36  /** Optional inline styles merged onto the root */37  style?: React.CSSProperties;38}39 40const EASES: Record<ScatterTextEase, (t: number) => number> = {41  "power2.out": (t) => 1 - Math.pow(1 - t, 3),42  "power3.out": (t) => 1 - Math.pow(1 - t, 4),43  "power4.out": (t) => 1 - Math.pow(1 - t, 5),44  "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),45  "back.out": (t) => {46    const c = 1.70158;47    const u = t - 1;48    return 1 + (c + 1) * u * u * u + c * u * u;49  },50  "elastic.out": (t) =>51    t <= 0 ? 0 : t >= 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,52  "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,53  "circ.out": (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),54};55 56/** Samples an ease into a CSS linear() timing function (falls back to the CSS default where unsupported). */57const toCssEasing = (name: ScatterTextEase) => {58  const fn = EASES[name] ?? EASES["expo.out"];59  const pts: string[] = [];60  for (let i = 0; i <= 48; i++) pts.push(String(Math.round(fn(i / 48) * 10000) / 10000));61  return `linear(${pts.join(", ")})`;62};63 64const rand = (n: number) => {65  const x = Math.sin(n * 9301 + 49297) * 233280;66  return x - Math.floor(x);67};68 69function staggerRanks(count: number, from: ScatterTextStaggerFrom): number[] {70  if (count <= 0) return [];71  const mid = (count - 1) / 2;72  if (from === "random") {73    const ranks = new Array<number>(count).fill(0);74    Array.from({ length: count }, (_, i) => i)75      .sort((a, b) => rand(a + 1) - rand(b + 1))76      .forEach((idx, r) => {77        ranks[idx] = r;78      });79    return ranks;80  }81  const raw = Array.from({ length: count }, (_, i) => {82    if (from === "end") return count - 1 - i;83    if (from === "center") return Math.abs(i - mid);84    if (from === "edges") return mid - Math.abs(i - mid);85    return i;86  });87  const min = Math.min(...raw);88  return raw.map((v) => v - min);89}90 91type Phase = "intro" | "assembled" | "scattered";92 93export function ScatterText({94  text = "Pieces fall into place",95  spread = 240,96  duration = 1.1,97  ease = "expo.out",98  stagger = 0.025,99  staggerFrom = "center",100  scatterOnHover = true,101  seed = 7,102  color,103  fontSize = 56,104  as = "h2",105  className = "",106  style,107}: ScatterTextProps) {108  const [phase, setPhase] = React.useState<Phase>("intro");109  const reducedRef = React.useRef(false);110  const words = React.useMemo(() => text.split(" "), [text]);111  const charCount = Array.from(text).length;112  const ranks = React.useMemo(() => staggerRanks(charCount, staggerFrom), [charCount, staggerFrom]);113  const wordStarts = React.useMemo(() => {114    let acc = 0;115    return words.map((w) => {116      const start = acc;117      acc += Array.from(w).length + 1;118      return start;119    });120  }, [words]);121 122  React.useEffect(() => {123    setPhase("intro");124    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;125    if (reducedRef.current) {126      setPhase("assembled");127      return;128    }129    let raf = requestAnimationFrame(() => {130      raf = requestAnimationFrame(() => setPhase("assembled"));131    });132    return () => cancelAnimationFrame(raf);133  }, [text, spread, seed, duration, ease, stagger, staggerFrom]);134 135  const onEnter = () => {136    if (scatterOnHover && !reducedRef.current && phase === "assembled") setPhase("scattered");137  };138  const onLeave = () => {139    if (phase === "scattered") setPhase("assembled");140  };141 142  const Tag = as;143  const vars = {144    "--mf-sc-size": `${fontSize}px`,145    "--mf-sc-duration": `${Math.max(0, duration)}s`,146    "--mf-sc-ease": toCssEasing(ease),147    ...(color ? { "--mf-sc-color": color } : {}),148    ...style,149  } as React.CSSProperties;150 151  return (152    <Tag153      className={`${styles.root} ${className}`.trim()}154      data-phase={phase}155      style={vars}156      onPointerEnter={onEnter}157      onPointerLeave={onLeave}158    >159      <span className={styles.srOnly}>{text}</span>160      <span aria-hidden="true" className={styles.letters}>161        {words.map((word, wi) => (162          <React.Fragment key={`${word}-${wi}`}>163            <span className={styles.word}>164              {Array.from(word).map((ch, ci) => {165                const i = wordStarts[wi] + ci;166                const s = seed * 131 + i * 7;167                const angle = rand(s) * Math.PI * 2;168                const dist = spread * (0.35 + rand(s + 1) * 0.65);169                return (170                  <span171                    key={i}172                    className={styles.char}173                    style={174                      {175                        "--mf-sc-x": `${(Math.cos(angle) * dist).toFixed(1)}px`,176                        "--mf-sc-y": `${(Math.sin(angle) * dist * 0.6).toFixed(1)}px`,177                        "--mf-sc-r": `${((rand(s + 2) - 0.5) * 300).toFixed(1)}deg`,178                        "--mf-sc-s": (0.4 + rand(s + 3) * 1.1).toFixed(2),179                        "--mf-sc-delay": `${((ranks[i] ?? 0) * stagger).toFixed(3)}s`,180                      } as React.CSSProperties181                    }182                  >183                    {ch}184                  </span>185                );186              })}187            </span>188            {wi < words.length - 1 ? " " : null}189          </React.Fragment>190        ))}191      </span>192    </Tag>193  );194}