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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scatter-textWrites 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.
1import { ScatterText } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScatterText />6 );7}Props
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
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}