Skip to main content

ns-ui / loud

Hero Isobar Contours

Hero background of drifting, breathing isobar-like contour lines whose density bunches tightly around the primary CTA and whose whole field leans toward the pointer, so the layout physically reads as a live pressure system centered on the one action that matters.

Use when a landing hero whose visual hierarchy is encoded structurally — line density converging on the CTA, reinforced by a live pressure field that breathes and leans toward the cursor — rather than hero-dipole-field, where cursor proximity is the whole mechanism, or chart-ridgeline-terrain, a canvas terrain sampling live data; pick this one when the persuasion has to survive prefers-reduced-motion as a still frame, since the density gradient alone still does the full hierarchy job with the drift, breathing and pointer-lean all frozen off.

Install

npx shadcn add https://design.helpmarq.com/r/hero-isobar-contours.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/loud/hero-isobar-contours/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// PressureFront — a hero background of isobar-like closed contour lines that
// bunch tightly around the primary CTA, so the composition physically leans
// toward the one action that matters. Pure SVG: every ring is a <path> whose
// `d` is recomputed directly (no canvas, no interpolation) on a throttled
// rAF loop, so `stroke` can reference --border/--ns-muted/--foreground natively
// and both themes repaint for free — no getComputedStyle color parsing at
// all. Line DENSITY (radial spacing that compresses near the CTA) is the
// hierarchy device, not color or a gradient fill.
//
// The field is genuinely alive at rest, not just on interaction: a
// continuous drift rotates the wobble, an independent faster breathing
// pulse swells/contracts the low-pressure centre, and it leans toward the
// pointer wherever it roams over the hero (not only when hovering the CTA).
// All three are additive perturbations on top of the same closed-form
// radius, never a point-attractor toward absolute coordinates — that keeps
// ~30 nested closed curves from ever converging into each other.
// ---------------------------------------------------------------------------

const RINGS = 30;
const ANGLES = 56; // vertices per ring — smooth at 1px stroke, cheap to redraw
const COMPRESSION = 2.15; // >1 bunches inner rings tightly, spreads outer ones
const ELLIPSE_X = 1.28; // rings are gently wide, matching a landscape hero
const PULL_PX = 8; // max inward pull on CTA hover/focus (deepening low)
const FRAME_INTERVAL = 1000 / 20; // throttled redraw rate — isobars drift slow, but smooth enough to read the pointer lean
const SPRING_K = 90;
const SPRING_ZETA = 0.8;
const BREATH_MS = 7000; // independent, faster period so the low visibly pulses instead of only slowly rotating
const BREATH_FRAC = 0.22; // +/- fraction minR itself swells by — a single global scalar, see drawRings for why
const POINTER_PULL_PX = 30; // max outward lean toward the pointer — stronger than the CTA's isotropic pull
const POINTER_GAP_CLAMP = 0.5; // never lean a ring past this fraction of its gap to the next ring; kept well under 1 because the existing wobble term already consumes part of that gap on its own

// Deterministic low-frequency harmonic sum standing in for 2D noise: it's
// exactly periodic in theta so every ring closes without a seam at 0/2π,
// which a sampled value-noise field can't guarantee without extra stitching.
function ringWobble(theta: number, phase: number, seed: number) {
  return (
    0.5 * Math.sin(3 * theta + phase + seed * 0.7) +
    0.3 * Math.sin(5 * theta - phase * 1.3 + seed * 1.3 + 1.7) +
    0.2 * Math.sin(8 * theta + phase * 0.6 + seed * 2.1 + 4.1)
  );
}

// Ring color steps toward the CTA — discrete steps, never a gradient. Colors
// are CSS custom properties resolved natively by the browser (no JS parsing).
function ringColor(i: number) {
  if (i < 2) return "color-mix(in srgb, var(--foreground) 25%, transparent)";
  if (i < 5) return "var(--ns-muted)";
  return "var(--border)";
}

export interface PressureFrontCta {
  label: string;
  href?: string;
  onClick?: () => void;
}

export interface PressureFrontProps {
  /** mono eyebrow label above the headline */
  eyebrow?: string;
  /** headline text; an array renders one line per entry */
  headline?: string | string[];
  /** supporting copy under the headline */
  subcopy?: string;
  /** required primary CTA button/link */
  primaryCta: PressureFrontCta;
  /** optional secondary CTA rendered beside the primary one */
  secondaryCta?: PressureFrontCta;
  /** number of contour rings. default 30 */
  rings?: number;
  /** ms for one full drift loop of the noise phase. default 20000 */
  driftMs?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function PressureFront({
  eyebrow,
  headline = "Ship the thing",
  subcopy,
  primaryCta,
  secondaryCta,
  rings = RINGS,
  driftMs = 20000,
  className = "",
}: PressureFrontProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const ctaRef = useRef<HTMLElement | null>(null);
  const pathRefs = useRef<(SVGPathElement | null)[]>([]);
  const dimsRef = useRef({ w: 0, h: 0 });
  const ctaCenterRef = useRef({ x: 0, y: 0 });

  const headlineLines = Array.isArray(headline) ? headline : [headline];

  useEffect(() => {
    const root = rootRef.current;
    const svg = svgRef.current;
    const cta = ctaRef.current;
    if (!root || !svg || !cta) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // -- measurement: CTA position re-derived on every resize --------------
    const measure = () => {
      const rootRect = root.getBoundingClientRect();
      const w = rootRect.width;
      const h = rootRect.height;
      dimsRef.current = { w, h };
      svg.setAttribute("viewBox", `0 0 ${Math.max(1, w)} ${Math.max(1, h)}`);
      const ctaRect = cta.getBoundingClientRect();
      ctaCenterRef.current = {
        x: ctaRect.left - rootRect.left + ctaRect.width / 2,
        y: ctaRect.top - rootRect.top + ctaRect.height / 2,
      };
    };
    measure();

    // -- geometry: recomputed and written directly to each <path d> --------
    // `breathPhase` runs on its own faster clock so the low-pressure centre
    // visibly pulses instead of only slowly rotating with the drift. Pointer
    // deformation is deliberately RADIAL and per-ring (never "toward a fixed
    // point in screen space"): each ring only ever swells along its own
    // center-relative radius, one-sided toward the cursor's bearing, clamped
    // to a fraction of its actual gap to the next ring. That keeps neighbor
    // rings moving in parallel — a point-attractor on ~30 nested closed
    // curves would make rings near the cursor's radius converge and cross.
    const drawRings = (
      phase: number,
      pullPx: number,
      breathPhase: number,
      pointerX: number,
      pointerY: number,
      pointerStrength: number
    ) => {
      const { w, h } = dimsRef.current;
      if (w < 1 || h < 1) return;
      const { x: ctaX, y: ctaY } = ctaCenterRef.current;
      const maxR = Math.max(w, h) * 0.92;
      const minR = Math.min(28, maxR * 0.05);

      // Breathing modulates minR as ONE global scalar, not a per-ring term —
      // deliberately, because the innermost rings are already packed within
      // a fraction of a px of each other (t^2.15 is nearly flat near t=0), so
      // any independent per-ring perturbation there risks flipping their
      // order. Folding the pulse into minR instead keeps every ring's radius
      // exactly (maxR - minRBreath) * t^COMPRESSION + minRBreath: the gap
      // between any two rings is (maxR - minRBreath) * (t_i^C - t_(i-1)^C),
      // always positive since maxR always exceeds minRBreath by construction
      // (BREATH_FRAC is a small fraction) — so the pulse can never cross
      // rings, only grow/shrink the whole low in lockstep, tapering to zero
      // at the outer edge for free via the same (1 - t^C) the compression
      // curve already has.
      const minRBreath = minR * (1 + BREATH_FRAC * Math.sin(breathPhase));

      // Pass 1: centers + base radii for every ring, so pass 2 can clamp the
      // pointer lean against each ring's *real* (post-breathing) neighbor gap.
      const centers: { x: number; y: number }[] = new Array(rings);
      const baseRs: number[] = new Array(rings);
      for (let i = 0; i < rings; i++) {
        const t = i / (rings - 1);
        const driftR = minR * 0.9;
        centers[i] = {
          x: ctaX + driftR * Math.cos(phase * 0.5 + i * 0.05),
          y: ctaY + driftR * 0.6 * Math.sin(phase * 0.5 + i * 0.05),
        };
        baseRs[i] = minRBreath + (maxR - minRBreath) * Math.pow(t, COMPRESSION);
      }

      for (let i = 0; i < rings; i++) {
        const el = pathRefs.current[i];
        if (!el) continue;
        const t = i / (rings - 1);
        const { x: cx, y: cy } = centers[i];
        const baseR = baseRs[i];
        const amp = (maxR - minR) * 0.045 * (0.35 + 0.65 * t);
        const seed = i * 0.53;
        const ringPhase = phase + i * 0.045;
        const pullHere = pullPx * (1 - t);

        // How much (and toward what bearing) this ring leans toward the
        // pointer: gaussian in RADIUS-space (how close the pointer's distance
        // from this ring's own center is to this ring's own radius), not
        // screen-space distance to a point — so the lean is inherently a
        // per-ring radial swell, not a convergent pull.
        let pointerAmp = 0;
        let pointerAngle = 0;
        if (pointerStrength > 0.001) {
          const dxp = pointerX - cx;
          const dyp = pointerY - cy;
          const distToCenter = Math.hypot(dxp, dyp);
          pointerAngle = Math.atan2(dyp, dxp);
          const distToRing = distToCenter - baseR;
          const sigma = Math.max(24, (maxR - minR) * 0.09);
          const ringFalloff = Math.exp(-(distToRing * distToRing) / (2 * sigma * sigma));
          const gapPrev = i > 0 ? Math.abs((baseRs[i] ?? baseR) - (baseRs[i - 1] ?? baseR)) : Math.abs((baseRs[1] ?? baseR) - (baseRs[0] ?? baseR));
          const gapNext = i < rings - 1 ? Math.abs((baseRs[i + 1] ?? baseR) - (baseRs[i] ?? baseR)) : gapPrev;
          const localGap = Math.min(gapPrev, gapNext);
          pointerAmp = Math.min(POINTER_PULL_PX, localGap * POINTER_GAP_CLAMP) * pointerStrength * ringFalloff;
        }

        let d = "";
        for (let k = 0; k < ANGLES; k++) {
          const theta = (k / ANGLES) * Math.PI * 2;
          const wob = amp * ringWobble(theta, ringPhase, seed);
          let r = baseR + wob - pullHere;
          if (pointerAmp > 0.01) {
            const align = Math.max(0, Math.cos(theta - pointerAngle));
            r += pointerAmp * align * align; // one-sided swell on the cursor-facing arc only
          }
          r = Math.max(4, r);
          const x = cx + r * Math.cos(theta) * ELLIPSE_X;
          const y = cy + r * Math.sin(theta);
          d += k === 0 ? `M${x.toFixed(1)},${y.toFixed(1)}` : `L${x.toFixed(1)},${y.toFixed(1)}`;
        }
        d += "Z";
        el.setAttribute("d", d);
      }
    };

    if (reduced) {
      // Static density gradient only: no phase drift, no continuous spring —
      // this is the strongest reduced-motion story in the set, since the
      // hierarchy is encoded structurally (ring spacing), not by motion.
      // No root-wide pointer tracking here on purpose — a redraw on every
      // pointermove is exactly the continuous motion prefers-reduced-motion
      // opts out of. Only the CTA's own enter/leave/focus/blur still toggles
      // a single static redraw, same as before.
      let staticPull = 0;
      drawRings(0, 0, 0, 0, 0, 0);
      const onEnter = () => {
        staticPull = PULL_PX;
        drawRings(0, staticPull, 0, 0, 0, 0);
      };
      const onLeave = () => {
        staticPull = 0;
        drawRings(0, staticPull, 0, 0, 0, 0);
      };
      cta.addEventListener("pointerenter", onEnter);
      cta.addEventListener("pointerleave", onLeave);
      cta.addEventListener("focus", onEnter);
      cta.addEventListener("blur", onLeave);
      const ro = new ResizeObserver(() => {
        measure();
        drawRings(0, staticPull, 0, 0, 0, 0);
      });
      ro.observe(root);
      const ctaRo = new ResizeObserver(() => {
        measure();
        drawRings(0, staticPull, 0, 0, 0, 0);
      });
      ctaRo.observe(cta);
      return () => {
        ro.disconnect();
        ctaRo.disconnect();
        cta.removeEventListener("pointerenter", onEnter);
        cta.removeEventListener("pointerleave", onLeave);
        cta.removeEventListener("focus", onEnter);
        cta.removeEventListener("blur", onLeave);
      };
    }

    // -- full loop: ambient drift + breathing + damped-spring hover pull +
    // pointer-follow lean -----------------------------------------------
    let raf = 0;
    let elVisible = true;
    let pageVisible = document.visibilityState === "visible";
    let visible = elVisible && pageVisible;
    let hoverOn = false;
    let startTime = 0;
    let lastTick = 0;
    let lastDraw = 0;
    let pull = 0;
    let pullVel = 0;

    // Pointer follow: raw target from the event, a smoothed trailing
    // position (framerate-normalized lerp, same idiom as chart-ridgeline-terrain),
    // and a spring-eased 0..1 strength so entering/leaving ramps rather than
    // snaps. Snaps straight to the raw position on the frame pointer
    // tracking (re)starts, so it doesn't sweep in from wherever it last was.
    let pointerRawX = 0;
    let pointerRawY = 0;
    let pointerX = 0;
    let pointerY = 0;
    let pointerActive = false;
    let pointerTracking = false;
    let pointerStrength = 0;
    let pointerStrengthVel = 0;

    const tick = (now: number) => {
      if (!startTime) startTime = now;
      const dt = lastTick ? Math.min(0.05, (now - lastTick) / 1000) : 1 / 60;
      lastTick = now;

      const target = hoverOn ? PULL_PX : 0;
      const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
      const accel = -SPRING_K * (pull - target) - c * pullVel;
      pullVel += accel * dt;
      pull += pullVel * dt;
      if (Math.abs(pull - target) < 0.02 && Math.abs(pullVel) < 0.02) {
        pull = target;
        pullVel = 0;
      }

      if (pointerActive) {
        if (!pointerTracking) {
          pointerX = pointerRawX;
          pointerY = pointerRawY;
          pointerTracking = true;
        } else {
          const k = 1 - Math.pow(0.88, dt * 60);
          pointerX += (pointerRawX - pointerX) * k;
          pointerY += (pointerRawY - pointerY) * k;
        }
      } else {
        pointerTracking = false;
      }
      const pointerTarget = pointerActive ? 1 : 0;
      const cp = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
      const accelP = -SPRING_K * (pointerStrength - pointerTarget) - cp * pointerStrengthVel;
      pointerStrengthVel += accelP * dt;
      pointerStrength += pointerStrengthVel * dt;
      if (Math.abs(pointerStrength - pointerTarget) < 0.002 && Math.abs(pointerStrengthVel) < 0.002) {
        pointerStrength = pointerTarget;
        pointerStrengthVel = 0;
      }

      if (now - lastDraw >= FRAME_INTERVAL) {
        lastDraw = now;
        const phase = ((now - startTime) / driftMs) * Math.PI * 2;
        const breathPhase = ((now - startTime) / BREATH_MS) * Math.PI * 2;
        drawRings(phase, pull, breathPhase, pointerX, pointerY, pointerStrength);
      }
      raf = visible ? requestAnimationFrame(tick) : 0;
    };
    const wake = () => {
      if (!raf && visible) raf = requestAnimationFrame(tick);
    };

    const onCtaOn = () => {
      hoverOn = true;
      wake();
    };
    const onCtaOff = () => {
      hoverOn = false;
      wake();
    };
    cta.addEventListener("pointerenter", onCtaOn);
    cta.addEventListener("pointerleave", onCtaOff);
    cta.addEventListener("focus", onCtaOn);
    cta.addEventListener("blur", onCtaOff);

    // Whole-hero pointer tracking — the field leans toward the cursor
    // wherever it is, not just when hovering the CTA. Mouse/pen only: touch
    // has no hover state and pointermove-during-scroll would read as jitter.
    // rect is re-measured on every move (matching chart-ridgeline-terrain's idiom)
    // rather than cached, so page scroll never throws the coordinates off.
    const onRootMove = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      const rect = root.getBoundingClientRect();
      pointerRawX = e.clientX - rect.left;
      pointerRawY = e.clientY - rect.top;
      pointerActive = true;
      wake();
    };
    const onRootLeave = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      pointerActive = false;
      wake();
    };
    root.addEventListener("pointermove", onRootMove);
    root.addEventListener("pointerleave", onRootLeave);

    const ro = new ResizeObserver(measure);
    ro.observe(root);
    const ctaRo = new ResizeObserver(measure);
    ctaRo.observe(cta);

    const io = new IntersectionObserver((entries) => {
      elVisible = entries[0]?.isIntersecting ?? true;
      visible = elVisible && pageVisible;
      wake();
    });
    io.observe(root);

    const onVisibility = () => {
      pageVisible = document.visibilityState === "visible";
      visible = elVisible && pageVisible;
      wake();
    };
    document.addEventListener("visibilitychange", onVisibility);

    wake();

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      ctaRo.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      cta.removeEventListener("pointerenter", onCtaOn);
      cta.removeEventListener("pointerleave", onCtaOff);
      cta.removeEventListener("focus", onCtaOn);
      cta.removeEventListener("blur", onCtaOff);
      root.removeEventListener("pointermove", onRootMove);
      root.removeEventListener("pointerleave", onRootLeave);
    };
  }, [rings, driftMs]);

  const ctaFocusRing =
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent";

  return (
    <section
      ref={rootRef}
      className={`relative isolate overflow-hidden bg-background ${className}`}
    >
      <svg
        ref={svgRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 h-full w-full"
      >
        {Array.from({ length: rings }).map((_, i) => (
          <path
            key={i}
            ref={(el) => {
              pathRefs.current[i] = el;
            }}
            fill="none"
            style={{ stroke: ringColor(i), strokeWidth: 1 }}
          />
        ))}
      </svg>

      <div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center px-6 pb-16 pt-24 text-center sm:pb-24 sm:pt-32">
        {eyebrow ? (
          <p className="mb-6 font-mono text-[11px] tracking-widest text-ns-muted">
            {eyebrow}
          </p>
        ) : null}
        <h1
          className="font-semibold text-foreground"
          style={{
            fontSize: "clamp(2.5rem, 6.5vw, 4.5rem)",
            lineHeight: 1.06,
            letterSpacing: "-0.03em",
          }}
        >
          {headlineLines.map((line, i) => (
            <span key={i} className="block">
              {line}
            </span>
          ))}
        </h1>
        {subcopy ? (
          <p className="mt-6 max-w-xl text-base leading-relaxed text-ns-muted">
            {subcopy}
          </p>
        ) : null}
        <div className="mt-9 flex flex-wrap items-center justify-center gap-3">
          {primaryCta.href ? (
            <a
              ref={(el) => {
                ctaRef.current = el;
              }}
              href={primaryCta.href}
              data-cta="primary"
              onClick={primaryCta.onClick}
              className={`rounded-sm bg-foreground px-5 py-2.5 text-sm font-medium text-background transition-opacity duration-200 hover:opacity-90 ${ctaFocusRing}`}
            >
              {primaryCta.label}
            </a>
          ) : (
            <button
              ref={(el) => {
                ctaRef.current = el;
              }}
              type="button"
              data-cta="primary"
              onClick={primaryCta.onClick}
              className={`rounded-sm bg-foreground px-5 py-2.5 text-sm font-medium text-background transition-opacity duration-200 hover:opacity-90 ${ctaFocusRing}`}
            >
              {primaryCta.label}
            </button>
          )}
          {secondaryCta ? (
            secondaryCta.href ? (
              <a
                href={secondaryCta.href}
                onClick={secondaryCta.onClick}
                className={`rounded-sm border border-border px-5 py-2.5 text-sm font-medium text-ns-muted transition-colors duration-200 hover:border-foreground/20 hover:text-foreground ${ctaFocusRing}`}
              >
                {secondaryCta.label}
              </a>
            ) : (
              <button
                type="button"
                onClick={secondaryCta.onClick}
                className={`rounded-sm border border-border px-5 py-2.5 text-sm font-medium text-ns-muted transition-colors duration-200 hover:border-foreground/20 hover:text-foreground ${ctaFocusRing}`}
              >
                {secondaryCta.label}
              </button>
            )
          ) : null}
        </div>
      </div>
    </section>
  );
}
Build spec

A hero whose background is ~30 closed isobar-like contour <path> rings, each one a plain SVG element whose stroke reads --border/--ns-muted/--foreground (and color-mix(in srgb, var(--foreground) 25%, transparent) for the innermost two) directly as CSS custom properties on the `style` attribute — no getComputedStyle, no canvas, no color parsing at all, so both themes repaint for free. Ring radius follows radius(t) = minR + (maxR - minR) * t^2.15 for t in [0,1] across the ring index, a convex power curve that packs the inner rings tightly and spreads the outer ones — this radial compression toward the CTA's measured center is the entire hierarchy device; color only steps twice (border to muted to foreground@25%) as secondary reinforcement, never a continuous gradient. Each ring is additionally perturbed by a deterministic low-frequency harmonic sum (three sine terms at ascending integer frequencies with descending amplitude, a closed-form stand-in for 2D noise) sampled directly in theta, which is exactly periodic so every ring closes without a stitching seam; each ring also carries a small per-index phase offset and a slightly orbiting center (a few px, cosine/sine of the drift phase) so the rings read as a layered weather-map field rather than N identical concentric circles. The CTA's on-screen center is measured via getBoundingClientRect relative to the hero's own bounding rect and re-measured on a ResizeObserver watching both the hero container and the CTA element itself, so layout reflow (font load, viewport resize, content wrap) never leaves the field anchored to a stale position. Geometry is recomputed directly every throttled tick (~20fps via a rAF accumulator, no interpolation between cached keyframes — the recompute itself is cheap closed-form trig, on the order of a couple thousand sin/cos calls, so no Web Worker is warranted) and the phase advances (elapsed / 20s) * 2*pi in a continuous loop that never settles, giving the field constant drift (tightened from an original 40s once that read as too subtle to register as alive at rest); the loop pauses on IntersectionObserver (offscreen) and document visibilitychange (backgrounded tab). A second, independent phase clock (7s period) drives a breathing pulse by modulating minR itself as a single global +/-22% scalar (never per-ring), so the low-pressure centre visibly swells and contracts on its own faster rhythm instead of the field reading as a slow uniform rotation; folding the pulse into minR rather than perturbing each ring's radius independently keeps every ring's gap to its neighbor at (maxR - minR_breathing) * (t_i^2.15 - t_(i-1)^2.15), always positive, so the pulse cannot invert ring order even where the innermost rings are packed within a fraction of a pixel of each other, and it tapers to zero at the outer edge for free via the same (1 - t^2.15) the compression curve already has. Hovering or focusing the primary CTA raises a target inward pull of 8px, integrated every animation frame (not just the throttled draw ticks) through a damped spring (k=90, zeta=0.8) and applied per ring scaled by (1 - t), so inner rings pull hardest and the effect reads as the low visibly deepening; releasing focus/hover springs it back. Separately, the whole hero tracks the pointer (mouse/pen, not touch) and every ring leans a bounded outward swell toward the cursor's bearing — one-sided via max(0, cos(theta - angleToPointer))^2, gaussian-weighted by how close the pointer's distance from that ring's own center is to that ring's own radius, and clamped to 50% of its real base gap to the neighboring ring, deliberately well under 100% because the pre-existing wobble term already consumes part of that same gap on its own — so the pointer term alone is always a parallel, non-convergent lean (never a point-attractor that would pull rings across each other), and in practice adjacent contours stay clearly separated on the cursor-facing arc even directly over the densely-packed centre; a spring-eased 0..1 strength ramps the effect in/out on pointer enter/leave rather than snapping. The SVG layer is aria-hidden and pointer-events-none, so it never interferes with the real interactive CTA underneath — pointer tracking is bound to the hero section itself, not the SVG. The breathing pulse and pointer-lean are both full-motion-only: under reduced motion neither the faster breathing clock nor root-wide pointer tracking ever starts (a continuous redraw on every pointermove is exactly the motion that preference opts out of), leaving only the CTA's existing single-toggle hover/focus pull. The primary CTA is a solid bg-foreground/text-background button — deliberately neutral, since --ns-accent is reserved for exactly one appearance in this whole component: the CTA's focus-visible outline, the same outline-2/outline-offset-2/outline-ns-accent pattern used registry-wide. The secondary CTA is a bordered ghost button with the same focus treatment (in --border/--foreground, not accent, keeping accent scarce). Under prefers-reduced-motion the phase is frozen at a single fixed instant and the continuous spring/rAF loop never starts at all — the static ring spacing alone still does the complete hierarchy job, and hover/focus on the CTA instantly toggles between the resting and pulled-in ring layout (a single redraw, not an animation) so the interaction stays legible without motion. Zero dependencies.

Props

PropTypeDefaultDescription
eyebrow?stringmono eyebrow label above the headline
headline?string | string[]"Ship the thing"headline text; an array renders one line per entry
subcopy?stringsupporting copy under the headline
primaryCtaPressureFrontCtarequired primary CTA button/link
secondaryCta?PressureFrontCtaoptional secondary CTA rendered beside the primary one
rings?numberRINGSnumber of contour rings. default 30
driftMs?number20000ms for one full drift loop of the noise phase. default 20000
className?stringextra classes merged onto the rendered root element