Hero de partículas que forman una palabra
Miles de puntos en canvas forman la palabra, huyen del puntero y regresan con un resorte; se pausa fuera de pantalla.
ORDEN
Miles de piezas sueltas que encuentran su lugar. Así trabajamos con operaciones que parecen un caos.
Empezar un diagnósticoMueve el puntero sobre la palabraCó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";
/** Hero de partículas: miles de puntos forman la palabra, huyen del puntero y regresan con un resorte. Canvas 2D; se pausa fuera de pantalla y en pestañas ocultas. React + TypeScript, sin dependencias. */
export type HeroParticulasProps = {
word?: string;
description?: string;
cta?: string;
href?: string;
colorA?: string;
colorB?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gchpa-root { box-sizing: border-box; position: relative; container-type: inline-size; width: 100%; min-height: 620px; overflow: hidden; color: #eef1ff; background: radial-gradient(120% 90% at 50% 0%, #141a3d, #070914 70%); font: 500 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; display: grid; }
.gchpa-root *, .gchpa-root *::before, .gchpa-root *::after { box-sizing: border-box; }
.gchpa-root :focus-visible { outline: 2px solid #9db4ff; outline-offset: 4px; }
.gchpa-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: pan-y; }
.gchpa-copy { position: relative; z-index: 1; align-self: end; display: grid; justify-items: center; gap: 18px; padding: 0 24px 56px; text-align: center; pointer-events: none; }
.gchpa-desc { margin: 0; max-width: 46ch; color: #aab3d6; font-size: clamp(15px, 1.8cqi, 19px); text-wrap: balance; }
.gchpa-cta { pointer-events: auto; display: inline-flex; align-items: center; min-height: 50px; padding: 0 28px; border-radius: 999px; background: #eef1ff; color: #0d1030; font-weight: 600; text-decoration: none; transition: transform .25s cubic-bezier(.16, 1, .3, 1), background .2s; }
.gchpa-cta:hover { transform: translateY(-2px); background: #fff; }
.gchpa-hint { font-size: 12px; color: #7a84ad; }
.gchpa-h1 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }`;
type P = { x: number; y: number; vx: number; vy: number; tx: number; ty: number; h: number };
export function HeroParticulas({ word = "ORDEN", description = "Miles de piezas sueltas que encuentran su lugar. Así trabajamos con operaciones que parecen un caos.", cta = "Empezar un diagnóstico", href = "#", colorA = "#6f8cff", colorB = "#ff7ad9", className = "", style }: HeroParticulasProps) {
const root = useRef<HTMLElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const cv = canvas.current;
const wrap = root.current;
if (!cv || !wrap) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let parts: P[] = [];
let w = 0;
let h = 0;
let dpr = 1;
let raf = 0;
let visible = true;
const pointer = { x: -9999, y: -9999, on: false };
function build() {
const rect = wrap!.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = rect.width;
h = rect.height;
cv!.width = Math.round(w * dpr);
cv!.height = Math.round(h * dpr);
const off = document.createElement("canvas");
off.width = w;
off.height = h;
const o = off.getContext("2d");
if (!o) return;
let size = Math.min(h * 0.46, w / (word.length * 0.62));
o.font = `900 ${size}px ui-sans-serif, system-ui, "Arial Black", sans-serif`;
while (o.measureText(word).width > w * 0.86 && size > 20) {
size -= 4;
o.font = `900 ${size}px ui-sans-serif, system-ui, "Arial Black", sans-serif`;
}
o.textAlign = "center";
o.textBaseline = "middle";
o.fillStyle = "#fff";
o.fillText(word, w / 2, h * 0.42);
const data = o.getImageData(0, 0, w, h).data;
const gap = w < 600 ? 5 : 6;
const next: P[] = [];
for (let y = 0; y < h; y += gap) {
for (let x = 0; x < w; x += gap) {
if (data[(Math.floor(y) * Math.floor(w) + Math.floor(x)) * 4 + 3] > 128) {
const old = parts[next.length];
next.push({ x: old ? old.x : Math.random() * w, y: old ? old.y : Math.random() * h, vx: 0, vy: 0, tx: x, ty: y, h: x / w });
}
}
}
parts = next;
}
function frame() {
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
for (const p of parts) {
if (!reduced) {
let ax = (p.tx - p.x) * 0.045;
let ay = (p.ty - p.y) * 0.045;
if (pointer.on) {
const dx = p.x - pointer.x;
const dy = p.y - pointer.y;
const d2 = dx * dx + dy * dy;
if (d2 < 130 * 130) {
const d = Math.sqrt(d2) || 1;
const f = (130 - d) / 130;
ax += (dx / d) * f * 6;
ay += (dy / d) * f * 6;
}
}
p.vx = (p.vx + ax) * 0.86;
p.vy = (p.vy + ay) * 0.86;
p.x += p.vx;
p.y += p.vy;
} else {
p.x = p.tx;
p.y = p.ty;
}
const speed = Math.min(1, Math.hypot(p.vx, p.vy) / 6);
ctx.fillStyle = speed > 0.12 ? "#ffffff" : p.h < 0.5 ? colorA : colorB;
ctx.globalAlpha = 0.55 + speed * 0.45;
ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
}
ctx.globalAlpha = 1;
raf = !reduced && visible && !document.hidden ? requestAnimationFrame(frame) : 0;
}
function start() {
if (!raf) raf = requestAnimationFrame(frame);
}
build();
frame();
const ro = new ResizeObserver(() => {
build();
start();
});
ro.observe(wrap);
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) start();
});
io.observe(wrap);
const onMove = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
const rect = cv.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.on = true;
start();
};
const onLeave = () => (pointer.on = false);
const onVis = () => !document.hidden && start();
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVis);
};
}, [word, colorA, colorB]);
return (
<section ref={root} className={`gchpa-root ${className}`} style={style}>
<style>{CSS}</style>
<h1 className="gchpa-h1">{word}</h1>
<canvas ref={canvas} className="gchpa-canvas" aria-hidden="true" />
<div className="gchpa-copy">
<p className="gchpa-desc">{description}</p>
<a className="gchpa-cta" href={href}>
{cta}
</a>
<span className="gchpa-hint">Mueve el puntero sobre la palabra</span>
</div>
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/hero-particulasEjemplo de uso de Hero de partículas que forman una palabra
Después de copiar hero-particulas.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { HeroParticulas } from "./hero-particulas";
export default function Ejemplo() {
return <HeroParticulas word="Ejemplo" />;
}Propiedades de HeroParticulas
HeroParticulas acepta 8 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
word? | string | Opcional. |
description? | string | Opcional. |
cta? | string | Opcional. |
href? | string | Opcional. |
colorA? | string | Opcional. |
colorB? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 175 líneas (7 KB) y no necesita @keyframes. Esto es lo que hace Hero de partículas que forman una palabra 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".