Ir al contenido
Evaluar mi proyecto
Componentes · HerosDisponible

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 palabra

Código completo

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

hero-particulas.tsx175 líneas
Descargar
"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.

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

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
word?stringOpcional.
description?stringOpcional.
cta?stringOpcional.
href?stringOpcional.
colorA?stringOpcional.
colorB?stringOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)