Ir al contenido
Evaluar mi proyecto
Componentes · Texto y animacionesDisponible

Letras con física

El puntero empuja las letras y vuelven a su sitio con un resorte; el bucle solo corre mientras hay movimiento.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

texto-repulsion.tsx100 líneas
Descargar
"use client";

import { useEffect, useRef, type CSSProperties } from "react";

/** Letras con física: el puntero las empuja y vuelven a su sitio con un resorte. El bucle solo corre mientras hay movimiento. React + TypeScript, sin dependencias. */
export type TextoRepulsionProps = {
  text?: string;
  radius?: number;
  strength?: number;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gctrr-root { display: inline-block; max-width: 100%; color: var(--gctrr-fg, #f2efe8); font: 800 clamp(38px, 9vw, 96px)/1.1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: -.04em; touch-action: pan-y; }
.gctrr-root *, .gctrr-root *::before, .gctrr-root *::after { box-sizing: border-box; }
.gctrr-c { display: inline-block; white-space: pre; will-change: transform; }`;

export function TextoRepulsion({ text = "Empuja las letras", radius = 120, strength = 1, className = "", style }: TextoRepulsionProps) {
  const chars = useRef<(HTMLSpanElement | null)[]>([]);
  const state = useRef<{ x: number; y: number; vx: number; vy: number }[]>([]);
  const pointer = useRef<{ x: number; y: number } | null>(null);
  const raf = useRef(0);
  const reduced = useRef(false);

  useEffect(() => {
    reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    state.current = Array.from(text, () => ({ x: 0, y: 0, vx: 0, vy: 0 }));
    return () => cancelAnimationFrame(raf.current);
  }, [text]);

  function tick() {
    let moving = false;
    chars.current.forEach((el, index) => {
      const s = state.current[index];
      if (!el || !s) return;
      const rect = el.getBoundingClientRect();
      const cx = rect.left + rect.width / 2 - s.x;
      const cy = rect.top + rect.height / 2 - s.y;
      let fx = -s.x * 0.08;
      let fy = -s.y * 0.08;
      const p = pointer.current;
      if (p) {
        const dx = cx - p.x;
        const dy = cy - p.y;
        const d = Math.hypot(dx, dy);
        if (d < radius && d > 0.01) {
          const push = ((radius - d) / radius) * 4 * strength;
          fx += (dx / d) * push;
          fy += (dy / d) * push;
        }
      }
      s.vx = (s.vx + fx) * 0.84;
      s.vy = (s.vy + fy) * 0.84;
      s.x += s.vx;
      s.y += s.vy;
      if (Math.abs(s.x) < 0.05 && Math.abs(s.y) < 0.05 && Math.abs(s.vx) < 0.05 && Math.abs(s.vy) < 0.05) {
        s.x = s.y = s.vx = s.vy = 0;
      } else moving = true;
      el.style.transform = `translate(${s.x.toFixed(2)}px, ${s.y.toFixed(2)}px)`;
    });
    raf.current = moving || pointer.current ? requestAnimationFrame(tick) : 0;
  }
  function start() {
    if (!raf.current && !reduced.current) raf.current = requestAnimationFrame(tick);
  }

  return (
    <span
      className={`gctrr-root ${className}`}
      style={style}
      aria-label={text}
      role="text"
      onPointerMove={(event) => {
        if (event.pointerType === "touch") return;
        pointer.current = { x: event.clientX, y: event.clientY };
        start();
      }}
      onPointerLeave={() => {
        pointer.current = null;
        start();
      }}
    >
      <style>{CSS}</style>
      {Array.from(text).map((char, index) => (
        <span
          key={index}
          ref={(node) => {
            chars.current[index] = node;
          }}
          className="gctrr-c"
          aria-hidden="true"
        >
          {char}
        </span>
      ))}
    </span>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/texto-repulsion

Ejemplo de uso de Letras con física

Después de copiar texto-repulsion.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { TextoRepulsion } from "./texto-repulsion";

export default function Ejemplo() {
  return <TextoRepulsion text="Ejemplo" />;
}

Propiedades de TextoRepulsion

TextoRepulsion acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
text?stringOpcional.
radius?numberOpcional.
strength?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Letras con física con variables CSS

Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.

VariableValor por defecto
--gctrr-fg#f2efe8

Accesibilidad y rendimiento

El archivo tiene 100 líneas (3 KB) y no necesita @keyframes. Esto es lo que hace Letras con física por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Declara roles ARIA para lectores de pantalla: text.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)