Lodestone
A field of hairlines that lines up like iron filings around your call to action. The button is a bar magnet, the pointer a second pole that bends the field toward it, the headline a stone the field flows around, and a success state swings every line to north in one wave.
- Background
- Interaction
- Canvas
Uses motion, clsx, tailwind-merge
Agents that pull in one direction
Meridian lines up every agent in your stack behind one goal, one plan and one log you can read.
"use client";
import * as React from "react";
import { Lodestone, LodestoneQuiet, LodestoneTarget } from "@/components/ui/lodestone";
export function LodestoneDemo() {
const [joined, setJoined] = React.useState(false);
const [email, setEmail] = React.useState("");
return (
<Lodestone
aligned={joined}
className="flex h-[var(--demo-height,36rem)] w-full flex-col items-center justify-center rounded-2xl border border-border bg-background px-6 text-center text-foreground"
>
<LodestoneQuiet className="flex flex-col items-center">
<span className="rounded-full border border-border bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
Private beta · 2,480 teams waiting
</span>
<h2 className="mt-6 max-w-[15ch] text-4xl/[1.05] font-semibold tracking-tight text-balance sm:text-6xl/[1.02]">
Agents that pull in one direction
</h2>
<p className="mt-5 max-w-md text-base/7 text-pretty text-muted-foreground">
Meridian lines up every agent in your stack behind one goal, one plan and one log you
can read.
</p>
</LodestoneQuiet>
<LodestoneTarget className="mt-10 h-12">
{joined ? (
<div className="flex h-12 items-center gap-3 rounded-full border border-border bg-background pr-2 pl-5 text-sm">
<span className="font-medium">You're in. Number 2,481.</span>
<button
type="button"
onClick={() => {
setJoined(false);
setEmail("");
}}
className="rounded-full px-3 py-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Undo
</button>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
setJoined(true);
}}
className="flex h-12 items-center rounded-full border border-border bg-background p-1 shadow-sm"
>
<label htmlFor="lodestone-email" className="sr-only">
Work email
</label>
<input
id="lodestone-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="h-full w-44 bg-transparent px-4 text-sm outline-none placeholder:text-muted-foreground sm:w-56"
/>
<button
type="submit"
className="h-10 rounded-full bg-foreground px-5 text-sm font-medium text-background transition-transform active:scale-[0.97]"
>
Join the waitlist
</button>
</form>
)}
</LodestoneTarget>
</Lodestone>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/lodestone.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/lodestoneInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/lodestone.tsx"use client"; import * as React from "react"; import { useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * LODESTONE STORYBOARD * * intro the first time the field scrolls into view, the * hairlines lie scattered like loose filings, then * snap into line in a ring that spreads out from the * call to action * rest every `LodestoneTarget` is a bar magnet: its field * arcs from one end of the button to the other and * loops out across the backdrop, so the whole surface * quietly leads back to it. Strong field: long, dark * hairlines that swing fast. Weak field: short, faint * ones that barely agree on a direction * cursor the pointer is a second, weaker pole. Its field * bends the loops toward it and, near the button, * bridges into the far end like iron filings between * two magnets * hover pointer or focus on a target: the cursor's pull lets * go and the target charges up. Its field takes over * in a wave that starts at the button, and the accent * color gathers around it * press a shockwave rolls out from the target; each hairline * twitches as it passes and springs back * aligned `aligned` (a waitlist joined, a form sent) swings * every hairline to north in one wave, with a ring * quiet `LodestoneQuiet` (a headline, a paragraph) is a * stone in the stream: the field parts and flows * along its edges, and nothing draws under the text * reduced the same field, drawn still: no intro, no cursor, * states change in one frame * ───────────────────────────────────────────────────────── */ const FIELD = { falloff: 1.5, // field of one pole ~ 1/r^falloff; a real magnet is 2, this reaches further soften: 14, // px, keeps the field finite right on a pole waveSpeed: 1150, // px/s, how fast a change spreads out from where it started tween: 0.55, // s, a target's charge or hover changing at one spot alignTween: 0.75, // s, swinging to north at one spot intro: 0.9, // s, a filing finding its line at one spot hoverBoost: 1.5, // a hovered target's charge, on top of 1 cursorFollow: 0.18, // s, time constant of the cursor's pole fading in and out stiffness: [55, 320], // spring stiffness from the weakest to the strongest field damping: 0.5, // damping ratio, a little under 1 so each hairline settles with a small overshoot jitter: 0.55, // rad, how much hairlines in a weak field disagree minLength: 0.22, // a hairline in no field is this share of `length` alpha: [0.06, 0.6], // opacity from the weakest to the strongest field accentReach: 1.2, // how far the accent gathers around a target, relative to its field ring: { speed: 820, width: 46, decay: 0.85, kick: 34 }, // shockwave: px/s, px, s, rad/s² marks: 6, // a static + every this many cells markAlpha: 0.2, fadeCells: 3, // edge fade, in cells levels: 14, // opacity steps hairlines are batched into accents: 4, // accent mix steps maxDpr: 2, } as const; type Tween = { from: number; to: number; t0: number; dur: number; ox: number; oy: number }; type Poles = "ends" | "center"; type Target = { el: HTMLElement; poles: Poles; strength: Tween; hover: Tween; hovered: boolean; // per frame, relative to the root x0: number; y0: number; x1: number; y1: number; }; type Options = { spacing: number; length: number; thickness: number; reach: number; cursor: number; intro: boolean; marks: boolean; fade: boolean; reduced: boolean; }; const clamp01 = (t: number) => Math.min(1, Math.max(0, t)); const smoothstep = (a: number, b: number, x: number) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); }; const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); /** A hairline has no head, so angles that differ by π are the same: wraps to (-π/2, π/2]. */ const wrapAxial = (a: number) => a - Math.PI * Math.round(a / Math.PI); const NORTH = Math.PI / 2; function still(value: number): Tween { return { from: value, to: value, t0: 0, dur: 0, ox: 0, oy: 0 }; } /** The tween's value at a point `d` px from where it started: the change reaches it `d / waveSpeed` s late. */ function tweenAt(tw: Tween, t: number, d: number) { if (tw.from === tw.to) return tw.to; const p = (t - tw.t0 - d / FIELD.waveSpeed) / tw.dur; if (p <= 0) return tw.from; if (p >= 1) return tw.to; return tw.from + (tw.to - tw.from) * easeInOut(p); } function tweenDone(tw: Tween, t: number, maxDistance: number) { return tw.from === tw.to || t > tw.t0 + tw.dur + maxDistance / FIELD.waveSpeed; } /** Signed distance from a point to a rounded box, and the outward normal there. */ function boxDistance( px: number, py: number, x0: number, y0: number, x1: number, y1: number, radius: number, out: { d: number; nx: number; ny: number }, ) { const cx = (x0 + x1) / 2; const cy = (y0 + y1) / 2; const sx = px < cx ? -1 : 1; const sy = py < cy ? -1 : 1; const qx = Math.abs(px - cx) - ((x1 - x0) / 2 - radius); const qy = Math.abs(py - cy) - ((y1 - y0) / 2 - radius); if (qx > 0 && qy > 0) { const len = Math.hypot(qx, qy); out.d = len - radius; out.nx = (sx * qx) / len; out.ny = (sy * qy) / len; } else if (qx > qy) { out.d = qx - radius; out.nx = sx; out.ny = 0; } else { out.d = qy - radius; out.nx = 0; out.ny = sy; } return out; } function createField() { let root: HTMLElement | null = null; let canvas: HTMLCanvasElement | null = null; let ctx: CanvasRenderingContext2D | null = null; let probes: { ink: HTMLElement; accent: HTMLElement } | null = null; let colors: string[] = []; let options: Options = { spacing: 22, length: 0.6, thickness: 1, reach: 1, cursor: 0.8, intro: true, marks: true, fade: true, reduced: false, }; const targets = new Map<string, Target>(); const quiet = new Map<string, HTMLElement>(); // Hairlines, rebuilt when the root changes size. let count = 0; let px = new Float32Array(0); let py = new Float32Array(0); let theta = new Float32Array(0); let omega = new Float32Array(0); let scatter = new Float32Array(0); // a filing's angle before the intro let jitter = new Float32Array(0); // -1…1, how this filing disagrees in a weak field let marks: number[] = []; let width = 0; let height = 0; let dpr = 1; let settled = false; // hairlines have been placed on their field once // Draw batches: one Float32Array of segments per opacity level × accent step. let batches: Float32Array[] = []; let batchCounts: Int32Array = new Int32Array(0); let aligned = still(0); let intro = still(1); let introStarted = false; const rings: { t0: number; x: number; y: number; amp: number }[] = []; const cursor = { x: 0, y: 0, inside: false, charge: 0 }; let raf = 0; let last = 0; let visible = false; const cleanups: (() => void)[] = []; const now = () => performance.now() / 1000; function readColors() { if (!probes) return; const ink = getComputedStyle(probes.ink).color; const accent = getComputedStyle(probes.accent).color; colors = []; for (let i = 0; i < FIELD.accents; i++) { const share = Math.round((i / (FIELD.accents - 1)) * 100); probes.ink.style.color = `color-mix(in oklab, ${ink}, ${accent} ${share}%)`; colors.push(getComputedStyle(probes.ink).color); } probes.ink.style.color = ""; } function layout() { if (!root || !canvas) return; const w = root.clientWidth; const h = root.clientHeight; dpr = Math.min(window.devicePixelRatio || 1, FIELD.maxDpr); if (w === width && h === height && canvas.width === Math.round(w * dpr)) return; width = w; height = h; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); const s = options.spacing; const cols = Math.max(1, Math.floor(w / s)); const rows = Math.max(1, Math.floor(h / s)); const ox = (w - (cols - 1) * s) / 2; const oy = (h - (rows - 1) * s) / 2; count = cols * rows; px = new Float32Array(count); py = new Float32Array(count); const oldTheta = theta; theta = new Float32Array(count); omega = new Float32Array(count); scatter = new Float32Array(count); jitter = new Float32Array(count); // Seeded, so the filings scatter the same way on every render. let seed = 1234567; const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); for (let r = 0, i = 0; r < rows; r++) { for (let c = 0; c < cols; c++, i++) { px[i] = ox + c * s; py[i] = oy + r * s; scatter[i] = (random() - 0.5) * Math.PI; jitter[i] = random() * 2 - 1; theta[i] = oldTheta.length === count ? oldTheta[i] : scatter[i]; } } marks = []; for (let r = 0; r < rows - 1; r++) { for (let c = 0; c < cols - 1; c++) { if ((r + 3) % FIELD.marks === 0 && (c + 3) % FIELD.marks === 0) { marks.push(ox + (c + 0.5) * s, oy + (r + 0.5) * s); } } } batches = Array.from({ length: FIELD.levels * FIELD.accents }, () => new Float32Array(count * 4)); batchCounts = new Int32Array(FIELD.levels * FIELD.accents); if (oldTheta.length !== count) settled = false; wake(); } function readRects() { if (!root) return { quietRects: [] as number[][] }; const base = root.getBoundingClientRect(); for (const target of targets.values()) { const r = target.el.getBoundingClientRect(); target.x0 = r.left - base.left; target.y0 = r.top - base.top; target.x1 = r.right - base.left; target.y1 = r.bottom - base.top; } const quietRects: number[][] = []; for (const el of quiet.values()) { const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; quietRects.push([r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top]); } return { quietRects }; } /** Where waves start: the strongest target, else the middle. */ function origin(): [number, number] { let best: Target | null = null; for (const target of targets.values()) { if (!best || target.strength.to > best.strength.to) best = target; } if (!best) return [width / 2, height / 2]; return [(best.x0 + best.x1) / 2, (best.y0 + best.y1) / 2]; } function retarget(tw: Tween, to: number, dur: number, ox: number, oy: number): Tween { if (tw.to === to) return tw; const t = now(); if (options.reduced) return still(to); // Restart from wherever the source spot is right now. return { from: tweenAt(tw, t, Math.hypot(ox - tw.ox, oy - tw.oy)), to, t0: t, dur, ox, oy }; } const box = { d: 0, nx: 0, ny: 0 }; function frame() { raf = 0; if (!ctx || !canvas || !root) return; const t = now(); const dt = Math.min(last ? t - last : 1 / 60, 1 / 20); last = t; const { quietRects } = readRects(); const { spacing, length, thickness, reach, reduced } = options; const maxDistance = Math.hypot(width, height); // The cursor's pole: fades in while the pointer is over the field and no target is hovered. let anyHover = false; for (const target of targets.values()) anyHover ||= target.hovered; const cursorGoal = cursor.inside && !anyHover && !reduced ? options.cursor : 0; cursor.charge = reduced ? cursorGoal : cursor.charge + (cursorGoal - cursor.charge) * (1 - Math.exp(-dt / FIELD.cursorFollow)); if (Math.abs(cursor.charge - cursorGoal) < 0.002) cursor.charge = cursorGoal; // Poles for this frame. Each target's charge is read per hairline, since changes travel as a wave. const list = [...targets.values()]; const L = Math.min(Math.max(0.32 * Math.min(width, height), 110), 280) * reach; const scale = Math.pow(L, FIELD.falloff); const power = (FIELD.falloff + 1) / 2; const soft2 = FIELD.soften * FIELD.soften; for (let i = rings.length - 1; i >= 0; i--) { if (t - rings[i].t0 > FIELD.ring.decay * 4) rings.splice(i, 1); } batchCounts.fill(0); let moving = false; const fadePx = spacing * FIELD.fadeCells; const [stiffMin, stiffMax] = FIELD.stiffness; const [alphaMin, alphaMax] = FIELD.alpha; for (let i = 0; i < count; i++) { const x = px[i]; const y = py[i]; // Edge fade and quiet zones (headlines, and the targets themselves) decide visibility. let visibility = options.fade ? smoothstep(0, fadePx, Math.min(x, y, width - x, height - y) + spacing * 0.5) : 1; let bx = 0; let by = 0; let tx = 0; let ty = 0; let hoverMax = 0; for (const target of list) { const cx = (target.x0 + target.x1) / 2; const cy = (target.y0 + target.y1) / 2; const strength = tweenAt(target.strength, t, Math.hypot(x - target.strength.ox, y - target.strength.oy)); const hover = tweenAt(target.hover, t, Math.hypot(x - target.hover.ox, y - target.hover.oy)); hoverMax = Math.max(hoverMax, hover); const q = strength * (1 + FIELD.hoverBoost * hover); if (q === 0) continue; if (target.poles === "ends") { const h = target.y1 - target.y0; const inset = Math.min(h / 2, (target.x1 - target.x0) / 4); // North pole on the left end, south on the right: the field runs from one to the other. for (const [ax, sign] of [ [target.x0 + inset, 1], [target.x1 - inset, -1], ] as const) { const dx = x - ax; const dy = y - cy; const f = (sign * q * scale) / Math.pow(dx * dx + dy * dy + soft2, power); tx += dx * f; ty += dy * f; } } else { const dx = x - cx; const dy = y - cy; const f = (-q * scale) / Math.pow(dx * dx + dy * dy + soft2, power); tx += dx * f; ty += dy * f; } boxDistance(x, y, target.x0, target.y0, target.x1, target.y1, Math.min(8, (target.y1 - target.y0) / 2), box); visibility *= smoothstep(spacing * 0.15, spacing * 0.75, box.d); } bx = tx; by = ty; if (cursor.charge > 0) { const dx = x - cursor.x; const dy = y - cursor.y; const f = (cursor.charge * scale) / Math.pow(dx * dx + dy * dy + soft2, power); bx += dx * f; by += dy * f; } for (const r of quietRects) { // A rounded, pebble-like outline, so the clearing never reads as a box. const pad = spacing * 0.5; const round = Math.min(r[2] - r[0], r[3] - r[1]) * 0.42 + pad; boxDistance(x, y, r[0] - pad, r[1] - pad, r[2] + pad, r[3] + pad, round, box); if (box.d < spacing * 3) { // Part around the stone: take away the part of the field that runs into it. const along = bx * box.nx + by * box.ny; const bend = 1 - smoothstep(0, spacing * 3, box.d); bx -= box.nx * along * bend; by -= box.ny * along * bend; } visibility *= smoothstep(0, spacing * 1.6, box.d); } const magnitude = Math.hypot(bx, by); const strengthHere = clamp01((Math.log2(magnitude + 1e-6) + 3.5) / 5); const targetHere = clamp01((Math.log2(Math.hypot(tx, ty) + 1e-6) + FIELD.accentReach) / 3.2); const a = tweenAt(aligned, t, Math.hypot(x - aligned.ox, y - aligned.oy)); const introAt = tweenAt(intro, t, Math.hypot(x - intro.ox, y - intro.oy)); let goal = magnitude > 1e-6 ? Math.atan2(by, bx) : theta[i]; goal += jitter[i] * FIELD.jitter * (1 - strengthHere) * (1 - strengthHere) * (1 - a); if (introAt < 1) goal = scatter[i] + wrapAxial(goal - scatter[i]) * introAt; if (a > 0) goal += wrapAxial(NORTH - goal) * a; const s = strengthHere + (0.72 - strengthHere) * a; // The shockwave: a ring that passes once, twitching hairlines as it goes. let pulse = 0; for (const ring of rings) { const age = t - ring.t0; const d = Math.hypot(x - ring.x, y - ring.y) - age * FIELD.ring.speed; pulse += ring.amp * Math.exp(-(d * d) / (FIELD.ring.width * FIELD.ring.width)) * Math.exp(-age / FIELD.ring.decay); } if (reduced || (!settled && !options.intro)) { theta[i] = goal; omega[i] = 0; } else { const k = stiffMin + (stiffMax - stiffMin) * s; const c = 2 * Math.sqrt(k) * FIELD.damping; const delta = wrapAxial(goal - theta[i]); omega[i] += (k * delta - c * omega[i] + pulse * FIELD.ring.kick) * dt; theta[i] = wrapAxial(theta[i] + omega[i] * dt); if (Math.abs(omega[i]) > 0.004 || Math.abs(delta) > 0.004) moving = true; } if (visibility <= 0.01) continue; const alpha = clamp01((alphaMin + (alphaMax - alphaMin) * s + 0.35 * pulse) * visibility); const level = Math.round(alpha * (FIELD.levels - 1)); if (level === 0) continue; const accent = clamp01(Math.max(targetHere * (0.3 + 0.7 * hoverMax) * (1 - a * 0.5), pulse * 0.8)); const shade = Math.round(accent * (FIELD.accents - 1)); const half = ((length * spacing) / 2) * (FIELD.minLength + (1 - FIELD.minLength) * s) * (1 + 0.5 * pulse); const dx = Math.cos(theta[i]) * half; const dy = Math.sin(theta[i]) * half; const b = level * FIELD.accents + shade; const n = batchCounts[b]++ * 4; const seg = batches[b]; seg[n] = x - dx; seg[n + 1] = y - dy; seg[n + 2] = x + dx; seg[n + 3] = y + dy; } settled = true; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.lineCap = "round"; ctx.lineWidth = thickness; for (let level = 1; level < FIELD.levels; level++) { for (let shade = 0; shade < FIELD.accents; shade++) { const b = level * FIELD.accents + shade; const n = batchCounts[b]; if (!n) continue; const seg = batches[b]; ctx.globalAlpha = level / (FIELD.levels - 1); ctx.strokeStyle = colors[shade] ?? colors[0] ?? "currentColor"; ctx.beginPath(); for (let j = 0; j < n * 4; j += 4) { ctx.moveTo(seg[j], seg[j + 1]); ctx.lineTo(seg[j + 2], seg[j + 3]); } ctx.stroke(); } } if (options.marks && marks.length) { ctx.strokeStyle = colors[0] ?? "currentColor"; ctx.lineWidth = thickness * 0.75; const arm = 2.5; ctx.beginPath(); let drawn = false; for (let j = 0; j < marks.length; j += 2) { const x = marks[j]; const y = marks[j + 1]; let visibility = options.fade ? smoothstep(0, fadePx, Math.min(x, y, width - x, height - y)) : 1; for (const r of quietRects) { boxDistance(x, y, r[0], r[1], r[2], r[3], 0, box); visibility *= smoothstep(0, spacing, box.d); } for (const target of list) { boxDistance(x, y, target.x0, target.y0, target.x1, target.y1, 0, box); visibility *= smoothstep(0, spacing, box.d); } if (visibility < 0.5) continue; ctx.moveTo(x - arm, y); ctx.lineTo(x + arm, y); ctx.moveTo(x, y - arm); ctx.lineTo(x, y + arm); drawn = true; } if (drawn) { ctx.globalAlpha = FIELD.markAlpha; ctx.stroke(); } } ctx.globalAlpha = 1; let busy = moving || rings.length > 0 || cursor.charge !== cursorGoal; busy ||= !tweenDone(aligned, t, maxDistance) || !tweenDone(intro, t, maxDistance); for (const target of list) { busy ||= !tweenDone(target.strength, t, maxDistance) || !tweenDone(target.hover, t, maxDistance); } if (busy && visible) raf = requestAnimationFrame(frame); else last = 0; } function wake() { if (raf || !visible || !ctx) return; raf = requestAnimationFrame(frame); } function startIntro() { if (introStarted) return; introStarted = true; if (!options.intro || options.reduced) { intro = still(1); return; } readRects(); const [ox, oy] = origin(); intro = { from: 0, to: 1, t0: now() + 0.15, dur: FIELD.intro, ox, oy }; } return { connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; ink: HTMLElement; accent: HTMLElement }) { root = el.root; canvas = el.canvas; ctx = canvas.getContext("2d"); probes = { ink: el.ink, accent: el.accent }; readColors(); width = 0; layout(); const resize = new ResizeObserver(() => { layout(); wake(); }); resize.observe(root); const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) { startIntro(); wake(); } }); io.observe(root); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); const recolor = () => { readColors(); wake(); }; scheme.addEventListener("change", recolor); const themeAttrs = new MutationObserver(recolor); themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] }); const move = (e: PointerEvent) => { if (!root) return; if (e.pointerType === "touch" && e.type === "pointermove" && !cursor.inside) return; const r = root.getBoundingClientRect(); cursor.x = e.clientX - r.left; cursor.y = e.clientY - r.top; cursor.inside = true; wake(); }; const leave = () => { cursor.inside = false; wake(); }; root.addEventListener("pointermove", move); root.addEventListener("pointerdown", move); root.addEventListener("pointerleave", leave); root.addEventListener("pointercancel", leave); const touchUp = (e: PointerEvent) => { if (e.pointerType === "touch") leave(); }; root.addEventListener("pointerup", touchUp); cleanups.push( () => resize.disconnect(), () => io.disconnect(), () => scheme.removeEventListener("change", recolor), () => themeAttrs.disconnect(), () => { root?.removeEventListener("pointermove", move); root?.removeEventListener("pointerdown", move); root?.removeEventListener("pointerleave", leave); root?.removeEventListener("pointercancel", leave); root?.removeEventListener("pointerup", touchUp); }, ); return () => { cancelAnimationFrame(raf); raf = 0; cleanups.splice(0).forEach((fn) => fn()); root = canvas = null; ctx = null; probes = null; }; }, setOptions(next: Options) { const relayout = next.spacing !== options.spacing; options = next; if (next.reduced) { intro = still(1); rings.length = 0; } if (relayout) width = 0; layout(); wake(); }, setAligned(value: boolean) { readRects(); const [ox, oy] = origin(); const goal = value ? 1 : 0; if (aligned.to === goal) return; aligned = retarget(aligned, goal, FIELD.alignTween, ox, oy); if (value && !options.reduced && introStarted) rings.push({ t0: now(), x: ox, y: oy, amp: 1 }); wake(); }, addTarget(id: string, el: HTMLElement, poles: Poles, strength: number) { const existing = targets.get(id); if (existing) existing.el = el; else { targets.set(id, { el, poles, strength: still(strength), hover: still(0), hovered: false, x0: 0, y0: 0, x1: 0, y1: 0, }); } wake(); return () => { targets.delete(id); wake(); }; }, updateTarget(id: string, poles: Poles, strength: number) { const target = targets.get(id); if (!target) return; target.poles = poles; if (target.strength.to !== strength) { readRects(); const ox = (target.x0 + target.x1) / 2; const oy = (target.y0 + target.y1) / 2; target.strength = retarget(target.strength, strength, FIELD.tween, ox, oy); } wake(); }, hoverTarget(id: string, hovered: boolean) { const target = targets.get(id); if (!target || target.hovered === hovered) return; target.hovered = hovered; readRects(); const ox = (target.x0 + target.x1) / 2; const oy = (target.y0 + target.y1) / 2; target.hover = retarget(target.hover, hovered ? 1 : 0, FIELD.tween, ox, oy); wake(); }, pulse(id: string) { const target = targets.get(id); if (!target || options.reduced) return; readRects(); rings.push({ t0: now(), x: (target.x0 + target.x1) / 2, y: (target.y0 + target.y1) / 2, amp: 0.7 }); wake(); }, addQuiet(id: string, el: HTMLElement) { quiet.set(id, el); wake(); return () => { quiet.delete(id); wake(); }; }, }; } type Field = ReturnType<typeof createField>; const LodestoneContext = React.createContext<Field | null>(null); export interface LodestoneProps extends React.HTMLAttributes<HTMLDivElement> { /** Distance between hairlines, in px. */ spacing?: number; /** Length of a hairline in a strong field, as a share of `spacing`. */ length?: number; /** Line width, in px. */ thickness?: number; /** How far the targets' fields carry. `2` reaches about twice as far. */ reach?: number; /** Strength of the pointer's own pole, relative to a target. `0` turns it off. */ cursor?: number; /** Swing every hairline to north in one wave from the target, for a success state. */ aligned?: boolean; /** Scatter the hairlines and let them snap into line the first time the field scrolls into view. */ intro?: boolean; /** Draw a faint static + every few cells, like engineering paper. */ marks?: boolean; /** Fade the field out toward the edges. */ fade?: boolean; /** Classes that set the hairlines' color (a `text-*` color). */ fieldClassName?: string; /** Classes that set the accent near a hovered target (a `text-*` color). */ accentClassName?: string; /** Classes for the canvas, e.g. an opacity. */ canvasClassName?: string; } export function Lodestone({ spacing = 24, length = 0.6, thickness = 1, reach = 1.3, cursor = 0.8, aligned = false, intro = true, marks = true, fade = true, fieldClassName, accentClassName, canvasClassName, className, children, ...props }: LodestoneProps) { const rootRef = React.useRef<HTMLDivElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null); const inkRef = React.useRef<HTMLSpanElement>(null); const accentRef = React.useRef<HTMLSpanElement>(null); const [field] = React.useState(createField); const reduced = useReducedMotion() ?? false; React.useLayoutEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const ink = inkRef.current; const accent = accentRef.current; if (!root || !canvas || !ink || !accent) return; return field.connect({ root, canvas, ink, accent }); }, [field]); React.useEffect(() => { field.setOptions({ spacing, length, thickness, reach, cursor, intro, marks, fade, reduced }); }, [field, spacing, length, thickness, reach, cursor, intro, marks, fade, reduced]); React.useEffect(() => { field.setAligned(aligned); }, [field, aligned]); return ( <LodestoneContext.Provider value={field}> <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} {...props}> <canvas ref={canvasRef} aria-hidden className={cn("pointer-events-none absolute inset-0 -z-10 size-full", canvasClassName)} /> {/* Probes the canvas reads its colors from. Defaults apply only when no class is given, so a `dark:` default can't win over yours. */} <span ref={inkRef} aria-hidden className={cn("hidden", fieldClassName ?? "text-zinc-900 dark:text-zinc-100")} /> <span ref={accentRef} aria-hidden className={cn("hidden", accentClassName ?? "text-indigo-500 dark:text-indigo-400")} /> {children} </div> </LodestoneContext.Provider> ); } export interface LodestoneTargetProps extends React.HTMLAttributes<HTMLDivElement> { /** How strongly this target pulls the field. Change it to hand the field from one target to another. */ strength?: number; /** `ends`: a bar magnet whose field arcs from one end to the other. `center`: one pole the field points into. */ poles?: Poles; } export function LodestoneTarget({ strength = 1, poles = "ends", className, onPointerEnter, onPointerLeave, onPointerDown, onFocus, onBlur, ...props }: LodestoneTargetProps) { const field = React.useContext(LodestoneContext); const ref = React.useRef<HTMLDivElement>(null); const id = React.useId(); const initial = React.useRef({ poles, strength }); React.useLayoutEffect(() => { const el = ref.current; if (!field || !el) return; return field.addTarget(id, el, initial.current.poles, initial.current.strength); }, [field, id]); React.useEffect(() => { field?.updateTarget(id, poles, strength); }, [field, id, poles, strength]); return ( <div ref={ref} className={cn("inline-flex", className)} onPointerEnter={(e) => { if (e.pointerType !== "touch") field?.hoverTarget(id, true); onPointerEnter?.(e); }} onPointerLeave={(e) => { field?.hoverTarget(id, false); onPointerLeave?.(e); }} onPointerDown={(e) => { field?.pulse(id); onPointerDown?.(e); }} onFocus={(e) => { if (e.target.matches(":focus-visible")) field?.hoverTarget(id, true); onFocus?.(e); }} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) field?.hoverTarget(id, false); onBlur?.(e); }} {...props} /> ); } export type LodestoneQuietProps = React.HTMLAttributes<HTMLDivElement>; /** Content the field flows around, like water around a stone. Nothing draws under it. */ export function LodestoneQuiet({ className, ...props }: LodestoneQuietProps) { const field = React.useContext(LodestoneContext); const ref = React.useRef<HTMLDivElement>(null); const id = React.useId(); React.useLayoutEffect(() => { const el = ref.current; if (!field || !el) return; return field.addQuiet(id, el); }, [field, id]); return <div ref={ref} className={className} {...props} />; }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Lodestone. Each one installs whole, with the component included.
Studio CTA
A studio's closing card in stone and orange. The button is a single pole (`poles="center"`), so the field fans into it from across the card, and the serif headline and the small print are both quiet zones.
Booking Q1 · 2 slots left
Have something you want people to feel?
shipping their first big thing.
import { Lodestone, LodestoneQuiet, LodestoneTarget } from "@/components/ui/lodestone";
export function LodestoneStudioDemo() {
return (
<Lodestone
spacing={20}
reach={1.1}
fieldClassName="text-stone-200"
accentClassName="text-orange-300"
className="flex min-h-[26rem] w-full max-w-4xl flex-col justify-between gap-12 rounded-3xl bg-stone-950 p-8 text-stone-100 sm:p-12"
>
<LodestoneQuiet className="max-w-md">
<p className="font-mono text-xs tracking-widest text-stone-400 uppercase">Booking Q1 · 2 slots left</p>
<h2 className="mt-5 font-serif text-4xl/[1.05] tracking-tight text-balance sm:text-5xl/[1.02]">
Have something you want people to feel?
</h2>
</LodestoneQuiet>
<div className="flex flex-wrap items-end justify-between gap-6">
<LodestoneQuiet className="text-sm/6 text-stone-400">
Brand, product and motion for teams
<br />
shipping their first big thing.
</LodestoneQuiet>
<LodestoneTarget poles="center">
<a
href="#book"
className="group inline-flex h-14 items-center gap-3 rounded-full bg-stone-100 pr-2 pl-7 text-base font-medium text-stone-950"
>
Book an intro call
<span className="grid size-10 place-items-center rounded-full bg-stone-950 text-stone-100 transition-transform duration-300 group-hover:rotate-[-45deg]">
<svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</span>
</a>
</LodestoneTarget>
</div>
</Lodestone>
);
}
Two Paths
A hero with a primary and a secondary call to action. The field rests on the primary; lean toward the other and its `strength` takes over, so the field swings across in a wave from that button.
Your first agent ships this afternoon
Start from a template on your own, or walk through your stack with an engineer.
"use client";
import * as React from "react";
import { Lodestone, LodestoneQuiet, LodestoneTarget } from "@/components/ui/lodestone";
type Path = "build" | "sales";
export function LodestonePathsDemo() {
// The field rests on the primary path and follows whichever one the visitor leans toward.
const [leaning, setLeaning] = React.useState<Path | null>(null);
const active = leaning ?? "build";
const lean = (path: Path) => ({
strength: active === path ? 1 : 0.12,
onPointerEnter: () => setLeaning(path),
onPointerLeave: () => setLeaning(null),
onFocus: () => setLeaning(path),
onBlur: () => setLeaning(null),
});
return (
<Lodestone
cursor={0.6}
className="flex h-[var(--demo-height,32rem)] w-full flex-col items-center justify-center rounded-2xl border border-border bg-background px-6 text-center text-foreground"
>
<LodestoneQuiet className="flex flex-col items-center">
<h2 className="max-w-[16ch] text-4xl/[1.05] font-semibold tracking-tight text-balance sm:text-5xl/[1.03]">
Your first agent ships this afternoon
</h2>
<p className="mt-4 max-w-sm text-base/7 text-pretty text-muted-foreground">
Start from a template on your own, or walk through your stack with an engineer.
</p>
</LodestoneQuiet>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<LodestoneTarget {...lean("build")}>
<a
href="#start"
className="inline-flex h-11 items-center rounded-full bg-foreground px-6 text-sm font-medium text-background"
>
Start building free
</a>
</LodestoneTarget>
<LodestoneTarget {...lean("sales")}>
<a
href="#sales"
className="inline-flex h-11 items-center rounded-full border border-border bg-background px-6 text-sm font-medium transition-colors hover:bg-muted"
>
Talk to an engineer
</a>
</LodestoneTarget>
</div>
</Lodestone>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | None | The section. Wrap the call to action in LodestoneTarget and the headline in LodestoneQuiet. |
aligned | boolean | false | Swings every hairline to north in one wave from the strongest target, with a ring. Use it for a success state: a waitlist joined, a form sent. |
cursor | number | 0.8 | Strength of the pointer's own pole, relative to a target. It fades out while a target is hovered. 0 turns it off. |
reach | number | 1.3 | How far the targets' fields carry across the backdrop. |
spacing | number | 24 | Distance between hairlines, in px. |
length | number | 0.6 | Length of a hairline in a strong field, as a share of spacing. In a weak field they shrink toward a quarter of that. |
thickness | number | 1 | Line width, in px. |
intro | boolean | true | Scatter the hairlines and let them snap into line, in a ring from the target, the first time the field scrolls into view. |
marks | boolean | true | Draw a faint static + every six cells, like engineering paper. |
fade | boolean | true | Fade the field out toward the edges. |
fieldClassName | string | "text-zinc-900 dark:text-zinc-100" | A text-* color for the hairlines. The canvas reads it, so dark: variants work; opacity comes from the field's strength. |
accentClassName | string | "text-indigo-500 dark:text-indigo-400" | A text-* color that gathers around a hovered target and rides the shockwave. |
canvasClassName | string | None | Classes for the canvas, e.g. opacity-60 to quiet the whole field. |
className | string | None | Classes for the section. Give it a background: the field draws behind the content, on top of it. |
target.strength | number | 1 | LodestoneTarget: how strongly this target pulls. Change it and the field hands over in a wave that starts at this target. |
target.poles | "ends" | "center" | "ends" | LodestoneTarget: ends makes it a bar magnet whose field arcs from one end to the other; center is a single pole the field points into. |
target.className | string | None | LodestoneTarget: classes for the wrapper, an inline-flex div. Hovering or keyboard-focusing it charges the target up; pressing it sends a shockwave. |
quiet.className | string | None | LodestoneQuiet: classes for the wrapper. The field parts around it on a rounded outline and draws nothing under it, so text stays readable. |
childrenRequiredType
React.ReactNodeThe section. Wrap the call to action in
LodestoneTargetand the headline inLodestoneQuiet.alignedType
booleanDefaultfalseSwings every hairline to north in one wave from the strongest target, with a ring. Use it for a success state: a waitlist joined, a form sent.
cursorType
numberDefault0.8Strength of the pointer's own pole, relative to a target. It fades out while a target is hovered.
0turns it off.reachType
numberDefault1.3How far the targets' fields carry across the backdrop.
spacingType
numberDefault24Distance between hairlines, in px.
lengthType
numberDefault0.6Length of a hairline in a strong field, as a share of
spacing. In a weak field they shrink toward a quarter of that.thicknessType
numberDefault1Line width, in px.
introType
booleanDefaulttrueScatter the hairlines and let them snap into line, in a ring from the target, the first time the field scrolls into view.
marksType
booleanDefaulttrueDraw a faint static + every six cells, like engineering paper.
fadeType
booleanDefaulttrueFade the field out toward the edges.
fieldClassNameType
stringDefault"text-zinc-900 dark:text-zinc-100"A
text-*color for the hairlines. The canvas reads it, sodark:variants work; opacity comes from the field's strength.accentClassNameType
stringDefault"text-indigo-500 dark:text-indigo-400"A
text-*color that gathers around a hovered target and rides the shockwave.canvasClassNameType
stringClasses for the canvas, e.g.
opacity-60to quiet the whole field.classNameType
stringClasses for the section. Give it a background: the field draws behind the content, on top of it.
target.strengthType
numberDefault1LodestoneTarget: how strongly this target pulls. Change it and the field hands over in a wave that starts at this target.target.polesType
"ends" | "center"Default"ends"LodestoneTarget:endsmakes it a bar magnet whose field arcs from one end to the other;centeris a single pole the field points into.target.classNameType
stringLodestoneTarget: classes for the wrapper, aninline-flexdiv. Hovering or keyboard-focusing it charges the target up; pressing it sends a shockwave.quiet.classNameType
stringLodestoneQuiet: classes for the wrapper. The field parts around it on a rounded outline and draws nothing under it, so text stays readable.