Ir al contenido
Evaluar mi proyecto
Componentes · Cursor y efectosDisponible

Rejilla magnética de puntos

Cada punto es atraído hacia el puntero con un resorte y crece al acercarse; el bucle se detiene cuando todo está en reposo.

Acerca el puntero a la rejilla

Código completo

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

efecto-iman.tsx93 líneas
Descargar
"use client";

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

/** Rejilla magnética: cada punto es atraído hacia el puntero con un resorte y crece al acercarse; al soltarlo vuelve a su sitio. Física en un solo bucle que se detiene cuando todo está en reposo. React + TypeScript, sin dependencias. */
export type EfectoImanProps = {
  cols?: number;
  rows?: number;
  radius?: number;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gceim-root { box-sizing: border-box; position: relative; display: grid; place-items: center; width: 100%; max-width: 640px; height: 320px; overflow: hidden; border-radius: 20px; background: #0e1014; color: #eef0f6; font: 500 15px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; touch-action: pan-y; }
.gceim-root *, .gceim-root *::before, .gceim-root *::after { box-sizing: border-box; }
.gceim-grid { display: grid; grid-template-columns: repeat(var(--c), 1fr); gap: 0; width: 86%; height: 78%; }
.gceim-cell { display: grid; place-items: center; }
.gceim-dot { width: 8px; height: 8px; border-radius: 50%; background: #5d6479; will-change: transform; }
.gceim-label { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font-size: 12px; color: #7b8296; pointer-events: none; }`;

export function EfectoIman({ cols = 14, rows = 7, radius = 110, className = "", style }: EfectoImanProps) {
  const root = useRef<HTMLDivElement>(null);
  const dots = useRef<(HTMLSpanElement | null)[]>([]);

  useEffect(() => {
    const el = root.current;
    if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const n = cols * rows;
    const st = Array.from({ length: n }, () => ({ x: 0, y: 0, vx: 0, vy: 0 }));
    const home: { cx: number; cy: number }[] = [];
    let ptr: { x: number; y: number } | null = null;
    let raf = 0;
    const measure = () => {
      dots.current.forEach((d, i) => {
        if (!d) return;
        const r = d.getBoundingClientRect();
        home[i] = { cx: r.left + r.width / 2 - st[i].x, cy: r.top + r.height / 2 - st[i].y };
      });
    };
    const tick = () => {
      let moving = false;
      for (let i = 0; i < n; i++) {
        const d = dots.current[i];
        const h = home[i];
        if (!d || !h) continue;
        const s = st[i];
        let fx = -s.x * 0.12, fy = -s.y * 0.12, grow = 0;
        if (ptr) {
          const dx = ptr.x - h.cx, dy = ptr.y - h.cy, dist = Math.hypot(dx, dy);
          if (dist < radius) {
            const k = 1 - dist / radius;
            fx += dx * k * 0.12;
            fy += dy * k * 0.12;
            grow = k;
          }
        }
        s.vx = (s.vx + fx) * 0.78;
        s.vy = (s.vy + fy) * 0.78;
        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 && !grow) { s.x = s.y = s.vx = s.vy = 0; d.style.transform = ""; d.style.background = ""; continue; }
        moving = true;
        d.style.transform = `translate(${s.x.toFixed(2)}px, ${s.y.toFixed(2)}px) scale(${1 + grow * 1.6})`;
        d.style.background = grow > 0.05 ? `hsl(${210 + grow * 80} 90% ${60 + grow * 15}%)` : "";
      }
      raf = moving || ptr ? requestAnimationFrame(tick) : 0;
    };
    const start = () => { if (!raf) raf = requestAnimationFrame(tick); };
    const move = (e: PointerEvent) => { if (e.pointerType === "touch") return; if (!home.length) measure(); ptr = { x: e.clientX, y: e.clientY }; start(); };
    const leave = () => { ptr = null; start(); };
    const ro = new ResizeObserver(() => { home.length = 0; });
    ro.observe(el);
    el.addEventListener("pointerenter", measure);
    el.addEventListener("pointermove", move);
    el.addEventListener("pointerleave", leave);
    return () => { cancelAnimationFrame(raf); ro.disconnect(); el.removeEventListener("pointerenter", measure); el.removeEventListener("pointermove", move); el.removeEventListener("pointerleave", leave); };
  }, [cols, rows, radius]);

  return (
    <div ref={root} className={`gceim-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gceim-grid" style={{ "--c": cols } as CSSProperties} aria-hidden="true">
        {Array.from({ length: cols * rows }, (_, i) => (
          <span key={i} className="gceim-cell">
            <span ref={(node) => { dots.current[i] = node; }} className="gceim-dot" />
          </span>
        ))}
      </div>
      <span className="gceim-label">Acerca el puntero a la rejilla</span>
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

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

Ejemplo de uso de Rejilla magnética de puntos

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

ejemplo.tsx5 líneas
import { EfectoIman } from "./efecto-iman";

export default function Ejemplo() {
  return <EfectoIman />;
}

Propiedades de EfectoIman

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

PropiedadTipoDescripción
cols?numberOpcional.
rows?numberOpcional.
radius?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 93 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Rejilla magnética de puntos por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • 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)