Fondo de puntos que reacciona al puntero
Rejilla de puntos en canvas con una onda lenta; cerca del puntero los puntos crecen, se desplazan y se encienden.
Pasa el puntero
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
/** Fondo de rejilla de puntos en canvas: una onda lenta recorre la malla y los puntos crecen y se desplazan cerca del puntero. Se pausa fuera de pantalla y con el botón. React + TypeScript, sin dependencias. */
export type FondoPuntosProps = {
color?: string;
accent?: string;
gap?: number;
children?: ReactNode;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcfpu-root { box-sizing: border-box; position: relative; isolation: isolate; container-type: inline-size; width: 100%; min-height: 480px; overflow: hidden; display: grid; place-items: center; background: #f4f1ea; color: #1a1814; font: 500 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcfpu-root *, .gcfpu-root *::before, .gcfpu-root *::after { box-sizing: border-box; }
.gcfpu-root :focus-visible { outline: 2px solid #1a1814; outline-offset: 3px; }
.gcfpu-canvas { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; display: block; }
.gcfpu-pause { position: absolute; right: 14px; bottom: 14px; z-index: 3; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border: 1px solid #1a1814; border-radius: 999px; background: #f4f1ea; color: #1a1814; font: 600 12px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
.gcfpu-pause:hover { background: #fff; }`;
export function FondoPuntos({ color = "#1a1814", accent = "#ff5a36", gap = 26, children, className = "", style }: FondoPuntosProps) {
const root = useRef<HTMLElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const [paused, setPaused] = useState(false);
const pausedRef = useRef(false);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
const startRef = useRef<() => void>(() => {});
useEffect(() => {
const wrap = root.current;
const cv = canvas.current;
const ctx = cv?.getContext("2d");
if (!wrap || !cv || !ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const t0 = performance.now();
let w = 0, h = 0, dpr = 1, raf = 0, visible = true;
const pointer = { x: -999, y: -999, on: false };
function size() {
const r = wrap!.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = r.width; h = r.height;
cv!.width = Math.round(w * dpr); cv!.height = Math.round(h * dpr);
}
function frame(now: number) {
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const t = reduced ? 0 : (now - t0) / 1000;
for (let y = gap / 2; y < h; y += gap) {
for (let x = gap / 2; x < w; x += gap) {
const wave = Math.sin(x * 0.012 + t * 0.9) * Math.cos(y * 0.014 - t * 0.7);
let r = 1.6 + wave * 1.1;
let ox = 0, oy = 0, hot = 0;
if (pointer.on) {
const dx = x - pointer.x, dy = y - pointer.y, d = Math.hypot(dx, dy);
if (d < 130) {
hot = 1 - d / 130;
r += hot * 4.5;
ox = (dx / (d || 1)) * hot * 12;
oy = (dy / (d || 1)) * hot * 12;
}
}
ctx.fillStyle = hot > 0.15 ? accent : color;
ctx.globalAlpha = 0.35 + (wave + 1) * 0.2 + hot * 0.4;
ctx.beginPath();
ctx.arc(x + ox, y + oy, Math.max(0.5, r), 0, 6.2832);
ctx.fill();
}
}
ctx.globalAlpha = 1;
raf = !reduced && visible && !pausedRef.current && !document.hidden ? requestAnimationFrame(frame) : 0;
}
const start = () => { if (!raf) raf = requestAnimationFrame(frame); };
startRef.current = start;
size();
requestAnimationFrame(frame);
const ro = new ResizeObserver(() => { size(); start(); });
ro.observe(wrap);
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); });
io.observe(wrap);
const move = (e: PointerEvent) => { if (e.pointerType === "touch") return; const r = cv.getBoundingClientRect(); pointer.x = e.clientX - r.left; pointer.y = e.clientY - r.top; pointer.on = true; start(); };
const leave = () => { pointer.on = false; start(); };
const vis = () => !document.hidden && start();
wrap.addEventListener("pointermove", move);
wrap.addEventListener("pointerleave", leave);
document.addEventListener("visibilitychange", vis);
return () => {
cancelAnimationFrame(raf);
ro.disconnect(); io.disconnect();
wrap.removeEventListener("pointermove", move);
wrap.removeEventListener("pointerleave", leave);
document.removeEventListener("visibilitychange", vis);
};
}, [color, accent, gap]);
return (
<section ref={root} className={`gcfpu-root ${className}`} style={style}>
<style>{CSS}</style>
<canvas ref={canvas} className="gcfpu-canvas" aria-hidden="true" />
{children}
<button
className="gcfpu-pause"
type="button"
aria-pressed={paused}
onClick={() => {
setPaused((value) => {
if (value) setTimeout(() => startRef.current(), 0);
return !value;
});
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">{paused ? <path d="M7 4v16l13-8z" /> : <path d="M6 4h4v16H6zM14 4h4v16h-4z" />}</svg>
{paused ? "Reanudar" : "Pausar"} fondo
</button>
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/fondo-puntosEjemplo de uso de Fondo de puntos que reacciona al puntero
Después de copiar fondo-puntos.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { FondoPuntos } from "./fondo-puntos";
export default function Ejemplo() {
return <FondoPuntos color="Ejemplo">Contenido</FondoPuntos>;
}Propiedades de FondoPuntos
FondoPuntos acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
color? | string | Opcional. |
accent? | string | Opcional. |
gap? | number | Opcional. |
children? | ReactNode | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 123 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Fondo de puntos que reacciona al puntero por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
- Dibuja en <canvas>, sin imágenes ni librerías gráficas.
- Se pausa cuando sale de la pantalla para no gastar batería ni CPU.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".