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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/texto-repulsionEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
text? | string | Opcional. |
radius? | number | Opcional. |
strength? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor 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".