Text FXAdvanced Text Animationsno dependencies

ElasticLetters

Headline whose letters bounce in on mount and then react to the pointer: letters near the cursor lift, stretch, thicken, and tint toward an accent color, driven by per-letter spring physics that settle with an elastic wobble.

Preview

Feel the spring

Customize

8 props
1
160
170
11
72

Installation

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

npx motionfoundry add elastic-letters

Writes components/ui/elastic-letters.tsx and components/styles/elastic-letters.module.css.

Usage

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

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

Props

PropTypeDefault
text

Text whose letters react to the pointer.

string'Feel the spring'
strength

Intensity of the pointer reaction.

number1
radius

Radius of pointer influence in pixels.

number160
stiffness

Spring stiffness; higher snaps back faster.

number170
damping

Spring damping; lower values wobble longer.

number11
intro

Play a staggered elastic drop-in when mounted.

booleantrue
accentColor

Tint that excited letters blend toward.

string'#38bdf8'
color

Resting text color (defaults to the theme foreground).

stringundefined
fontSize

Font size in pixels.

number72
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 per-letter spans are aria-hidden. The pointer effect is purely decorative and needs no keyboard equivalent. Under prefers-reduced-motion there is no intro and no pointer reaction. The spring loop sleeps when settled, pauses off-screen via IntersectionObserver, and is cancelled on unmount.

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

Source

components/ui/elastic-letters.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/elastic-letters.module.css";5 6export interface ElasticLettersProps {7  /** Text whose letters react to the pointer */8  text?: string;9  /** Intensity of the pointer reaction (1 = default lift and stretch) */10  strength?: number;11  /** Radius of pointer influence in pixels */12  radius?: number;13  /** Spring stiffness; higher snaps back faster */14  stiffness?: number;15  /** Spring damping; lower values wobble longer */16  damping?: number;17  /** Play a staggered elastic drop-in when mounted */18  intro?: boolean;19  /** Tint that excited letters blend toward */20  accentColor?: string;21  /** Resting text color (defaults to the theme foreground) */22  color?: string;23  /** Font size in pixels */24  fontSize?: number;25  /** Element rendered as the root */26  as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";27  /** Optional className merged onto the root */28  className?: string;29  /** Optional inline styles merged onto the root */30  style?: React.CSSProperties;31}32 33interface LetterState {34  cx: number;35  cy: number;36  y: number;37  vy: number;38  s: number;39  vs: number;40  k: number;41  vk: number;42  release: number;43}44 45const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;46 47export function ElasticLetters({48  text = "Feel the spring",49  strength = 1,50  radius = 160,51  stiffness = 170,52  damping = 11,53  intro = true,54  accentColor = "#38bdf8",55  color,56  fontSize = 72,57  as = "h2",58  className = "",59  style,60}: ElasticLettersProps) {61  const rootRef = React.useRef<HTMLElement | null>(null);62  const words = React.useMemo(() => text.split(/\s+/).filter(Boolean), [text]);63 64  useIsoLayoutEffect(() => {65    const root = rootRef.current;66    if (!root) return;67    const els = Array.from(root.querySelectorAll<HTMLElement>("[data-mf-el-char]"));68    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;69    if (reduced || els.length === 0) return;70 71    const lift = fontSize * 0.42;72    const states: LetterState[] = els.map((_, i) => ({73      cx: 0,74      cy: 0,75      y: intro ? -fontSize * 1.1 : 0,76      vy: 0,77      s: intro ? 0.2 : 1,78      vs: 0,79      k: 0,80      vk: 0,81      release: intro ? 0.08 + i * 0.045 : 0,82    }));83 84    const measure = () => {85      els.forEach((el, i) => {86        states[i].cx = el.offsetLeft + el.offsetWidth / 2;87        states[i].cy = el.offsetTop + el.offsetHeight / 2;88      });89    };90    measure();91 92    const render = (el: HTMLElement, st: LetterState, opacity: number) => {93      const sy = st.s;94      const sx = 1 + (st.s - 1) * 0.45;95      el.style.transform = `translate3d(0, ${st.y.toFixed(2)}px, 0) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;96      el.style.opacity = opacity >= 1 ? "" : opacity.toFixed(3);97      el.style.setProperty("--mf-el-k", Math.max(0, Math.min(1, st.k)).toFixed(3));98    };99    els.forEach((el, i) => render(el, states[i], intro ? 0 : 1));100 101    const pointer = { x: 0, y: 0, inside: false };102    let raf = 0;103    let running = false;104    let visible = true;105    let last = 0;106    const start = performance.now();107    const k = Math.max(1, stiffness);108    const c = Math.max(0, damping);109    const rad = Math.max(1, radius);110 111    const tick = (now: number) => {112      raf = 0;113      if (!visible) {114        running = false;115        return;116      }117      const dt = Math.min(1 / 30, Math.max(0.001, (now - last) / 1000));118      last = now;119      const t = (now - start) / 1000;120      let moving = false;121      states.forEach((st, i) => {122        const released = t >= st.release;123        let ty = st.y;124        let ts = st.s;125        let tk = 0;126        if (released) {127          let f = 0;128          if (pointer.inside) {129            const dx = pointer.x - st.cx;130            const dy = pointer.y - st.cy;131            const d = Math.sqrt(dx * dx + dy * dy);132            f = Math.max(0, 1 - d / rad);133            f = f * f * (3 - 2 * f);134          }135          ty = -lift * strength * f;136          ts = 1 + 0.38 * strength * f;137          tk = f;138        } else {139          moving = true;140        }141        if (released) {142          // Semi-implicit Euler spring per channel.143          st.vy += (k * (ty - st.y) - c * st.vy) * dt;144          st.y += st.vy * dt;145          st.vs += (k * (ts - st.s) - c * st.vs) * dt;146          st.s += st.vs * dt;147          st.vk += (k * (tk - st.k) - c * 1.6 * st.vk) * dt;148          st.k += st.vk * dt;149          const restless =150            Math.abs(st.vy) > 0.05 ||151            Math.abs(ty - st.y) > 0.05 ||152            Math.abs(st.vs) > 0.001 ||153            Math.abs(ts - st.s) > 0.001 ||154            Math.abs(tk - st.k) > 0.002;155          if (restless) moving = true;156        }157        const opacity = released ? Math.min(1, (t - st.release) / 0.18) : 0;158        if (opacity < 1) moving = true;159        render(els[i], st, opacity);160      });161      if (moving || pointer.inside) {162        raf = requestAnimationFrame(tick);163      } else {164        running = false;165      }166    };167 168    const wake = () => {169      if (running || !visible) return;170      running = true;171      last = performance.now();172      raf = requestAnimationFrame(tick);173    };174 175    const onMove = (e: PointerEvent) => {176      const rect = root.getBoundingClientRect();177      pointer.x = e.clientX - rect.left;178      pointer.y = e.clientY - rect.top;179      pointer.inside = true;180      wake();181    };182    const onLeave = () => {183      pointer.inside = false;184      wake();185    };186 187    root.addEventListener("pointermove", onMove);188    root.addEventListener("pointerleave", onLeave);189 190    let ro: ResizeObserver | null = null;191    if (typeof ResizeObserver !== "undefined") {192      ro = new ResizeObserver(() => measure());193      ro.observe(root);194    }195    let io: IntersectionObserver | null = null;196    if (typeof IntersectionObserver !== "undefined") {197      io = new IntersectionObserver((entries) => {198        visible = entries.some((e) => e.isIntersecting);199        if (visible) wake();200      });201      io.observe(root);202    }203 204    wake();205 206    return () => {207      if (raf) cancelAnimationFrame(raf);208      root.removeEventListener("pointermove", onMove);209      root.removeEventListener("pointerleave", onLeave);210      ro?.disconnect();211      io?.disconnect();212      els.forEach((el) => {213        el.style.transform = "";214        el.style.opacity = "";215        el.style.removeProperty("--mf-el-k");216      });217    };218  }, [text, strength, radius, stiffness, damping, intro, fontSize]);219 220  const Tag = as;221  const vars = {222    "--mf-el-size": `${fontSize}px`,223    "--mf-el-accent": accentColor,224    ...(color ? { "--mf-el-color": color } : {}),225    ...style,226  } as React.CSSProperties;227 228  let index = 0;229  return (230    <Tag231      ref={(el: HTMLElement | null) => {232        rootRef.current = el;233      }}234      className={`${styles.root} ${className}`.trim()}235      style={vars}236    >237      <span className={styles.srOnly}>{text}</span>238      <span aria-hidden="true" className={styles.letters}>239        {words.map((word, wi) => (240          <React.Fragment key={`${word}-${wi}`}>241            <span className={styles.word}>242              {Array.from(word).map((ch) => {243                const i = index++;244                return (245                  <span key={i} className={styles.char} data-mf-el-char="">246                    {ch}247                  </span>248                );249              })}250            </span>251            {wi < words.length - 1 ? " " : null}252          </React.Fragment>253        ))}254      </span>255    </Tag>256  );257}