Skip to content
Plinth

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
Open in v0
'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>
    </>
  );
}

More in Text Effects

Similar tags