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