Full-bleed hero where the headline exists twice: crisp DOM type above, and beneath it a canvas field of iron-filing strokes solved from a two-pole dipole field, so the type visibly iron-files into existence as the cursor approaches and CTA hover bends the whole field toward the button.
npx shadcn add https://design.helpmarq.com /r/hero-dipole-field.jsonregistry/core/hero-dipole-field/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// LodestoneHero — full-bleed hero where the headline exists twice: crisp DOM
// type above, and beneath it a canvas field of iron-filing strokes solved from
// a two-pole dipole field (cursor pole + fixed anchor pole behind the primary
// CTA). Filings pack densely only inside the headline's letter-mask, so the
// type visibly "iron-files" into existence as the cursor approaches; hovering
// the CTA doubles its pole strength and bends the whole field toward the
// button. Vector-field solver rendering: each filing is a short line segment
// whose angle chases atan2 of the summed field vector with critically-damped
// easing. Canvas 2D, refs-only hot path, breath-cycle ambient drift with real
// sleep windows between breaths.
// ---------------------------------------------------------------------------
type Vec3 = [number, number, number];
function parseColor(raw: string): Vec3 | null {
const s = raw.trim();
if (s.startsWith("#")) {
const hex = s.slice(1);
if (hex.length === 3) {
const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
return null;
}
const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
function mix(a: Vec3, b: Vec3, t: number): Vec3 {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
// deterministic prng — filing placement is stable across re-renders
function mulberry32(a: number) {
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 2-octave value noise — seeds each filing's ambient-drift character
function hash2(x: number, y: number) {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
return n - Math.floor(n);
}
function vnoise(x: number, y: number) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const xf = x - xi;
const yf = y - yi;
const u = xf * xf * (3 - 2 * xf);
const v = yf * yf * (3 - 2 * yf);
const a = hash2(xi, yi);
const b = hash2(xi + 1, yi);
const c = hash2(xi, yi + 1);
const d = hash2(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function noise2(x: number, y: number) {
return 0.65 * vnoise(x, y) + 0.35 * vnoise(x * 2.1 + 19.7, y * 2.1 + 7.3);
}
function easeOutExpo(p: number) {
return p >= 1 ? 1 : 1 - Math.pow(2, -10 * p);
}
// time-based pole-strength tween — duration IS the forced-settle deadline
// (max 600ms < 800ms budget), so a spring can never hunt forever
type Tween = { from: number; to: number; t0: number; dur: number; value: number; active: boolean };
function retarget(tw: Tween, to: number, dur: number, now: number) {
if (!tw.active && tw.value === to) return;
tw.from = tw.value;
tw.to = to;
tw.t0 = now;
tw.dur = dur;
tw.active = true;
}
function tickTween(tw: Tween, now: number) {
if (!tw.active) return;
const p = (now - tw.t0) / tw.dur;
if (p >= 1) {
tw.value = tw.to;
tw.active = false;
} else {
tw.value = tw.from + (tw.to - tw.from) * easeOutExpo(p);
}
}
const POLE_RMIN = 48; // r clamp — field never blows up at the pole core
const MAG_SOFT = 5.5e-5; // soft-knee normalizer for magnitude → alpha
const IDLE_MS = 500; // pointer-idle threshold before ambient drift fades in
const DRIFT_HZ = 0.1; // ambient oscillation frequency
const DRIFT_AMP = (8 * Math.PI) / 180; // ±8°
const BREATH_S = 12; // ambient breath cycle length
const ACTIVE_FRAC = 0.72; // fraction of the cycle that drifts; rest = sleep
const SLEEP_EPS = 0.001; // max per-filing angular delta below which we sleep
export function LodestoneHero({
eyebrow = "FIELD-ALIGNED INFRASTRUCTURE",
headlineLines = ["Every signal bends", "toward your stack"],
subcopy = "Lodestone routes traffic the way a magnet organizes iron filings: declare the pole, and every request, retry, and rollback aligns itself. No orchestration YAML, no drift.",
primaryCta = { label: "Deploy the field", href: "#deploy" },
secondaryCta = { label: "Read the docs", href: "#docs" },
stats = [
{ value: "12ms", label: "p99 route solve" },
{ value: "99.98%", label: "field uptime" },
{ value: "4,200+", label: "clusters aligned" },
],
maskFilings = 1800,
ambientFilings = 600,
className = "",
}: {
/** mono eyebrow line above the headline */
eyebrow?: string;
/** display headline, one string per rendered line — this is the letter-mask source */
headlineLines?: string[];
/** muted supporting copy under the headline */
subcopy?: string;
/** primary accent CTA — its center is the fixed anchor pole */
primaryCta?: { label: string; href: string };
/** ghost secondary CTA */
secondaryCta?: { label: string; href: string };
/** three mono stats for the bordered strip below the CTAs */
stats?: { value: string; label: string }[];
/** filings rejection-sampled inside the headline letter-mask */
maskFilings?: number;
/** sparse ambient filings outside the mask */
ambientFilings?: number;
className?: string;
}) {
const rootRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const headlineRef = useRef<HTMLHeadingElement>(null);
const ctaRef = useRef<HTMLAnchorElement>(null);
const lineRefs = useRef<(HTMLSpanElement | null)[]>([]);
const linesKey = headlineLines.join("