Original by Plinth · MIT
Text Effects
Gravity Letters
Letters shove away from your cursor and snap back past centre, wobbling into place.
#text#pointer#physics#spring#animation
Install
npx shadcn@latest add https://plinthui.com/r/gravity-letters.json'use client';
import { useRef } from 'react';
// The spring easing and the pointer-driven custom properties are outside what
// Tailwind can express, so the material lives in the style element below.
const css = `
.gravity__word {
display: flex;
margin: 0;
font: 700 clamp(30px, 11vw, 76px)/1 ui-sans-serif, system-ui, -apple-system, sans-serif;
letter-spacing: 0.01em;
color: #f4f1ea;
}
.gravity__letter {
display: inline-block;
/* pre, or the spaces between words collapse the moment each character is
wrapped in its own span. */
white-space: pre;
transform:
translate3d(var(--dx, 0px), var(--dy, 0px), 0)
rotate(var(--rot, 0deg));
/* The control point above 1 is the whole character of this: letters
overshoot and settle rather than glide home. */
transition: transform 620ms cubic-bezier(0.18, 1.62, 0.42, 1);
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.gravity__letter { transition: none; transform: none; }
}
`;
/** Reach of the cursor, how hard it shoves, and how much it twists. */
const RANGE = 115;
const FORCE = 26;
const SPIN = 15;
export function GravityLetters({ text = 'GRAVITY' }: { text?: string }) {
const wordRef = useRef<HTMLParagraphElement>(null);
function eachLetter(fn: (el: HTMLElement) => void) {
const word = wordRef.current;
if (!word) return;
for (const el of Array.from(word.querySelectorAll<HTMLElement>('.gravity__letter'))) fn(el);
}
function shove(event: React.PointerEvent<HTMLDivElement>) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
eachLetter(letter => {
const r = letter.getBoundingClientRect();
const dx = r.left + r.width / 2 - event.clientX;
const dy = r.top + r.height / 2 - event.clientY;
const dist = Math.hypot(dx, dy) || 1;
if (dist > RANGE) {
letter.style.setProperty('--dx', '0px');
letter.style.setProperty('--dy', '0px');
letter.style.setProperty('--rot', '0deg');
return;
}
// Squared falloff: linear would drift the whole word as a slab, while
// this makes the letter under the cursor leap and its neighbours stir.
const t = 1 - dist / RANGE;
const push = t * t * FORCE;
letter.style.setProperty('--dx', `${(dx / dist) * push}px`);
letter.style.setProperty('--dy', `${(dy / dist) * push}px`);
letter.style.setProperty('--rot', `${(dx / dist) * t * SPIN}deg`);
});
}
function release() {
eachLetter(letter => {
letter.style.setProperty('--dx', '0px');
letter.style.setProperty('--dy', '0px');
letter.style.setProperty('--rot', '0deg');
});
}
return (
<>
<style>{css}</style>
<div
onPointerMove={shove}
onPointerLeave={release}
style={{
display: 'grid',
placeItems: 'center',
alignContent: 'center',
minHeight: 260,
padding: 24,
background: '#0b0a10',
cursor: 'crosshair',
overflow: 'hidden',
}}
>
{/* The word is announced once from aria-label; the per-letter spans are
hidden, or a screen reader spells it out one character at a time. */}
<p className="gravity__word" ref={wordRef} aria-label={text}>
{text.split('').map((ch, i) => (
<span className="gravity__letter" aria-hidden="true" key={`${ch}-${i}`}>
{ch}
</span>
))}
</p>
</div>
</>
);
}