Hero Particles WEBGL

Hero

Full-viewport hero with a cursor-reactive WebGL particle field and staggered text reveal.

Install
npx shadcn add https://design.helpmarq.com/r/hero-particles-webgl.json
Source
registry/core/hero-particles-webgl/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { motion, useReducedMotion, type Variants } from "motion/react";

const COUNT = 6000;
const FIELD = { w: 22, h: 12 };
// repulsion radius in screen pixels; converted to world units per-frame
// since world-units-per-pixel changes with viewport size
const CURSOR_RADIUS_PX = 170;
// peak outward displacement as a fraction of the radius
const PUSH = 0.62;
// resting cursor position, far outside the field — falloff is 0 here, so
// easing back toward it lets every dot relax to its home position
const REST = new THREE.Vector2(999, 999);
// exponential ease factor for the cursor uniform: low enough that dots trail
// the pointer and spring back rather than snapping
const EASE = 0.09;

// All particle motion lives in the vertex shader — zero per-particle CPU work.
// JS only eases the cursor uniform each frame so the repulsion trails smoothly,
// and re-derives uColor from CSS tokens on theme change.
const vertexShader = /* glsl */ `
  uniform float uTime;
  uniform vec2 uCursor;
  uniform float uRadius;
  uniform float uPush;
  attribute float aSeed;
  void main() {
    vec3 p = position;
    // ambient drift — two desynced sines per dot
    p.x += sin(uTime * 0.4 + aSeed) * 0.3;
    p.y += cos(uTime * 0.32 + aSeed * 1.7) * 0.3;
    // reversed-polarity magnet: displace outward along (dot - cursor),
    // strongest at the pointer, decaying smoothly to zero at uRadius
    vec2 d = p.xy - uCursor;
    float dist = length(d);
    float falloff = 1.0 - smoothstep(0.0, uRadius, dist);
    // ease-out shaping so the shove is soft at the rim and firm at the core
    falloff = falloff * falloff * (3.0 - 2.0 * falloff);
    vec2 dir = dist > 0.0001 ? d / dist : vec2(0.0);
    p.xy += dir * falloff * uRadius * uPush;
    vec4 mv = modelViewMatrix * vec4(p, 1.0);
    gl_PointSize = 42.0 / -mv.z;
    gl_Position = projectionMatrix * mv;
  }
`;

const fragmentShader = /* glsl */ `
  uniform vec3 uColor;
  void main() {
    float d = length(gl_PointCoord - 0.5);
    float alpha = smoothstep(0.5, 0.15, d) * 0.55;
    if (alpha < 0.01) discard;
    gl_FragColor = vec4(uColor, alpha);
  }
`;

// #rrggbb / #rgb -> 0-1 float triple; falls back to muted gray on parse miss
function parseColor(hex: string): [number, number, number] | null {
  const s = hex.trim();
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

function Particles({ still, visible }: { still: boolean; visible: RefObject<boolean> }) {
  const material = useRef<THREE.ShaderMaterial>(null);
  const pointerInside = useRef(false);
  // scratch vector for the per-frame ease target — avoids an allocation on
  // every frame the pointer is inside the canvas
  const target = useRef(new THREE.Vector2());
  const { viewport, pointer, size, gl } = useThree();

  const { positions, seeds } = useMemo(() => {
    const positions = new Float32Array(COUNT * 3);
    const seeds = new Float32Array(COUNT);
    for (let i = 0; i < COUNT; i++) {
      positions[i * 3] = (Math.random() - 0.5) * FIELD.w;
      positions[i * 3 + 1] = (Math.random() - 0.5) * FIELD.h;
      positions[i * 3 + 2] = 0;
      seeds[i] = Math.random() * Math.PI * 2;
    }
    return { positions, seeds };
  }, []);

  const uniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uCursor: { value: REST.clone() },
      uRadius: { value: 1 },
      uPush: { value: 0 },
      uColor: { value: new THREE.Vector3(0.561, 0.561, 0.561) },
    }),
    []
  );

  // track pointer enter/leave on the canvas element directly — pointer.x/y
  // from useThree freezes at the last in-bounds sample on leave, so without
  // this the cursor uniform would never ease back to rest
  useEffect(() => {
    const el = gl.domElement;
    const enter = () => (pointerInside.current = true);
    const leave = () => (pointerInside.current = false);
    el.addEventListener("pointerenter", enter);
    el.addEventListener("pointerleave", leave);
    return () => {
      el.removeEventListener("pointerenter", enter);
      el.removeEventListener("pointerleave", leave);
    };
  }, [gl]);

  // derive dot color from CSS tokens at mount, and again whenever the theme
  // class flips — mirrors the pattern used by other canvas components in
  // this registry (e.g. hero-long-exposure)
  useEffect(() => {
    const derive = () => {
      const u = material.current?.uniforms;
      if (!u) return;
      const cs = getComputedStyle(document.documentElement);
      const base = parseColor(cs.getPropertyValue("--muted")) ?? [0.561, 0.561, 0.561];
      (u.uColor.value as THREE.Vector3).set(...base);
    };
    derive();
    const mo = new MutationObserver(derive);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => mo.disconnect();
  }, []);

  useFrame(({ clock }) => {
    if (!material.current || !visible.current) return; // paused while scrolled offscreen
    const u = material.current.uniforms;
    // radius is defined in screen pixels; convert using this frame's
    // world-units-per-pixel so the void stays ~170px regardless of
    // viewport size or DPR
    u.uRadius.value = (CURSOR_RADIUS_PX * viewport.width) / size.width;
    if (still) return; // reduced motion: freeze drift and repulsion
    u.uTime.value = clock.elapsedTime;

    const inside = pointerInside.current;
    const t = target.current;
    if (inside) t.set((pointer.x * viewport.width) / 2, (pointer.y * viewport.height) / 2);
    else t.copy(REST);
    // exponential ease toward target — dots swell outward as the pointer
    // approaches and relax home behind it
    const c = u.uCursor.value as THREE.Vector2;
    c.x += (t.x - c.x) * EASE;
    c.y += (t.y - c.y) * EASE;
    // fade the push in on enter / out on leave so nothing pops
    const p = u.uPush as { value: number };
    p.value += ((inside ? PUSH : 0) - p.value) * EASE;
  });

  return (
    <points>
      <bufferGeometry>
        <bufferAttribute attach="attributes-position" args={[positions, 3]} />
        <bufferAttribute attach="attributes-aSeed" args={[seeds, 1]} />
      </bufferGeometry>
      <shaderMaterial
        ref={material}
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={uniforms}
        transparent
        depthWrite={false}
      />
    </points>
  );
}

function useWebGLSupport() {
  const [supported, setSupported] = useState(true);
  useEffect(() => {
    const canvas = document.createElement("canvas");
    setSupported(!!(canvas.getContext("webgl2") || canvas.getContext("webgl")));
  }, []);
  return supported;
}

const reveal: Variants = {
  hidden: { opacity: 0, y: 24, filter: "blur(8px)" },
  show: (i: number) => ({
    opacity: 1,
    y: 0,
    filter: "blur(0px)",
    transition: { delay: 0.15 + i * 0.12, type: "spring", stiffness: 90, damping: 16 },
  }),
};

const ctaClasses =
  "inline-flex items-center justify-center rounded-sm bg-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";

const revealProps = (reduced: boolean, i: number) =>
  ({ variants: reveal, initial: reduced ? false : "hidden", animate: "show", custom: i }) as const;

export function ParticleHero({
  eyebrow = "ns-ui",
  headline = "Interfaces with gravity.",
  subline = "A registry of components built one at a time, each earning its place.",
  cta = "Browse components",
  ctaHref,
  onCtaClick,
}: {
  eyebrow?: string;
  headline?: string;
  subline?: string;
  cta?: string;
  ctaHref?: string;
  onCtaClick?: () => void;
}) {
  const webgl = useWebGLSupport();
  const reduced = useReducedMotion() ?? false;
  const sectionRef = useRef<HTMLElement>(null);
  const visible = useRef(true);

  // pause the rAF-driven drift/cursor work while the hero is scrolled offscreen
  useEffect(() => {
    const el = sectionRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      visible.current = entry.isIntersecting;
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <section
      ref={sectionRef}
      className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background"
    >
      {webgl ? (
        <div className="absolute inset-0" aria-hidden>
          <Canvas camera={{ position: [0, 0, 8], fov: 50 }} dpr={[1, 2]}>
            <Particles still={reduced} visible={visible} />
          </Canvas>
        </div>
      ) : (
        // static fallback when WebGL is unavailable
        <div
          aria-hidden
          className="absolute inset-0 [background-image:radial-gradient(circle,var(--color-border)_1px,transparent_1px)] [background-size:22px_22px]"
        />
      )}
      {/* vignette keeps edges quiet and text legible; pointer-events-none so
          the cursor reaches the canvas underneath (it blocked all hover) */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_50%_50%,transparent_30%,var(--color-background)_100%)]"
      />
      <div className="relative z-10 mx-auto max-w-2xl px-6 text-center">
        <motion.p
          className="font-mono text-xs uppercase tracking-[0.2em] text-muted"
          {...revealProps(reduced, 0)}
        >
          {eyebrow}
        </motion.p>
        <motion.h1
          className="mt-4 text-5xl font-semibold tracking-tight sm:text-6xl"
          {...revealProps(reduced, 1)}
        >
          {headline}
        </motion.h1>
        <motion.p
          className="mx-auto mt-5 max-w-lg text-base text-muted"
          {...revealProps(reduced, 2)}
        >
          {subline}
        </motion.p>
        <motion.div
          className="mt-8"
          {...revealProps(reduced, 3)}
        >
          {ctaHref ? (
            <a href={ctaHref} className={ctaClasses}>
              {cta}
            </a>
          ) : (
            <button onClick={onCtaClick} className={ctaClasses}>
              {cta}
            </button>
          )}
        </motion.div>
      </div>
    </section>
  );
}
Use when

a hero with a real WebGL particle field the cursor repels — an elastic void that trails the pointer — plus staggered text reveal; the only hero in this set with npm deps (three, @react-three/fiber, motion), so pick it when true GPU depth is worth the heavier install.

Build spec

A dark full-viewport hero section: a WebGL particle field of thousands of small gray dots that drift gently, and the cursor acts as a reversed-polarity magnet — dots within roughly 170px are displaced outward along the (dot - cursor) direction with a smooth falloff, opening an elastic void that trails the pointer and springs back to rest when it leaves. No glow, no halo, no linking lines; brightness and dot size never change. Behind it sits a staggered spring-physics text reveal (mono eyebrow, large tight headline, muted subline) and a single accent CTA, with a radial vignette keeping edges quiet. Strictly monochrome dots from the muted token, no accent. Static dot-grid fallback without WebGL, reduced-motion respected (drift and repulsion frozen).

Tags
herowebglparticlestext-reveal