Half the points sit on an exact lattice and half are seeded noise; fifteen relaxation sweeps then push the noisy ones apart until no neighbour pair is closer than 40% of the mean spacing. The result keeps the liveliness of random placement without the clusters that read as a bug. The visualiser leaves a dashed ghost at each point's pre-relaxation angle, so the displacement is legible, and prints the smallest gap before and after.
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Relaxed Seeded ScatterHalf the points sit on an exact lattice and half are seeded noise; fifteen relaxation sweeps then push the noisy ones apart until no neighbour pair is closer than 40% of the mean spacing. The result keeps the liveliness of random placement without the clusters that read as a bug. The visualiser leaves a dashed ghost at each point's pre-relaxation angle, so the displacement is legible, and prints the smallest gap before and after.Build this as a React component in my project. Match my existing conventions —file layout, styling system, and naming — rather than the ones implied below.Everything after this line is specification, not code to copy verbatim.## What it doesSeeded angles around a ring, pushed apart until nothing collides. Ghost ticks mark where they started.- Category: primitive- Runtime cost: dom- Dependencies: none beyond React## How it works1. `floor(count * evenRatio)` points are written at `(i / fixedCount) * 2PI` and flagged fixed; the remainder get `rng() * 2PI` from `createRng(seed)`, a mulberry32 stream. The raw array is copied before anything moves so the two states can be compared and interpolated.2. The target gap is `(2PI / count) * 0.4` — 40% of the mean spacing on the full ring, not on the random subset, so the floor means the same thing regardless of how many points are pinned.3. Each sweep sorts the nodes by `mod(angle, 2PI)`, then walks consecutive pairs measuring `gap = mod(next.angle - current.angle, 2PI)`. Because `mod` always measures the short way round, the pair that straddles zero needs no special case.4. When a gap is under target, each free endpoint is moved by `(target - gap) * 0.5` away from the other; if one endpoint is pinned to the lattice the free one absorbs the whole `(target - gap)` instead, or points pile up against the lattice and never separate.5. Angles are stored unwrapped — a point pushed below zero keeps a small negative value rather than jumping to just under 2PI — so the component can interpolate `raw -> relaxed` linearly and never take the long way round. Only the sort and the gap measurement wrap.6. The visualiser eases that interpolation over 1.4 s with `easeOutQuart` on the shared rAF loop, writing the `transform` attribute of one `<g>` per point. The SVG uses a `-50 -50 100 100` viewBox, so it resizes with its container and needs no ResizeObserver.## Constants — use these valuesThese are tuned. Each one has a reason; do not round them off or replace themwith values that "look cleaner".- `RELAX_PASSES = 15` Each sweep removes about half the remaining overlap, so 15 leaves 2^-15 of the initial error — fully converged for any count up to ~64. At 3 passes the tightest pairs are still visibly paired; past ~20 nothing measurable changes and you are paying O(passes · n log n) for nothing.- `MIN_GAP_RATIO = 0.4` Floor on the neighbour gap as a fraction of the mean, so `targetGap = 0.4 * 2PI / count`. Below ~0.25 clusters survive and the scatter still reads as clumped. Above ~0.6 the relaxation cannot satisfy every pair at once, so it oscillates between passes and converges toward the lattice — which throws away the randomness you asked for.- `EVEN_RATIO = 0.5` Fraction pinned to the exact lattice. At 0 the composition has no skeleton and the eye finds no structure; at 1 it is a clock face. Half and half is the mix that reads as deliberate but not mechanical.- `PUSH_SHARE = 0.5` Each free neighbour absorbs half the deficit, so a pair separates by exactly the deficit per sweep. At 1.0 both move the full amount, overshoot, and the pair oscillates instead of settling.- `MORPH_DURATION = 1.4` Seconds for the raw-to-relaxed sweep. Long enough to follow individual points leaving their ghosts; under ~0.6 s it reads as a single cut and the comparison is lost.- `seed = 'kinetic'` Any fixed value works — what matters is that it is fixed. `hashSeed` turns the string into a 32-bit integer, so a named seed per instance gives every ring its own reproducible composition.## API```tsfunction relaxedRingAngles(count: number, seed: number | string, options?: { passes?: number; minGapRatio?: number; evenRatio?: number; pushShare?: number }): RelaxedRing;function RelaxedSeededScatter(props: { count?: number; seed?: number | string; passes?: number; minGapRatio?: number; evenRatio?: number; morph?: boolean; className?: string }): JSX.Element```- `count: number` — default `24` Points on the ring.- `seed: number | string` — default `'kinetic'` Fixed seed. Strings are hashed with `hashSeed`.- `passes: number` — default `15` Relaxation sweeps.- `minGapRatio: number` — default `0.4` Minimum neighbour gap as a fraction of the mean spacing.- `evenRatio: number` — default `0.5` Fraction of points pinned to an exact lattice.- `morph: boolean` — default `true` Ease from the raw angles to the relaxed ones on first view.- `className: string` Merged onto the outer element via `cn`.## Reference implementation of the coreThis is the part that is hard to get right. Adapt it; do not treat the rest ofthe component as fixed.```tsx/** Sweeps. Each halves the residual overlap; 15 is fully converged for n<=64. */const RELAX_PASSES = 15;/** Floor on the neighbour gap, as a fraction of the mean gap. */const MIN_GAP_RATIO = 0.4;/** Half the points sit on an exact lattice and never move. */const EVEN_RATIO = 0.5;/** Each free neighbour absorbs half the deficit. */const PUSH_SHARE = 0.5;type Node = { id: number; angle: number; fixed: boolean };/** Smallest neighbour gap on the circle, and how many pairs are under `target`. */function ringStats(values: ArrayLike<number>, target: number) { const n = values.length; if (n < 2) return { minGap: TAU, crowded: 0 }; const sorted = Array.from(values, (v) => mod(v, TAU)).sort((a, b) => a - b); let minGap = TAU; let crowded = 0; for (let i = 0; i < n; i++) { const a = sorted[i] ?? 0; const b = sorted[(i + 1) % n] ?? 0; const gap = i === n - 1 ? b + TAU - a : b - a; if (gap < minGap) minGap = gap; if (gap < target) crowded++; } return { minGap, crowded };}export function relaxedRingAngles( count: number, seed: number | string, options: RelaxedRingOptions = {},): RelaxedRing { const { passes = RELAX_PASSES, minGapRatio = MIN_GAP_RATIO, evenRatio = EVEN_RATIO, pushShare = PUSH_SHARE, } = options; const n = Math.max(0, Math.floor(count)); const angles = new Float32Array(n); const raw = new Float32Array(n); const targetGap = n > 0 ? (TAU / n) * minGapRatio : 0; const fixedCount = Math.floor(n * evenRatio); if (n === 0) { return { angles, raw, minGap: 0, rawMinGap: 0, crowded: 0, rawCrowded: 0, targetGap, fixedCount }; } // Half order: an exact lattice, so the composition has a skeleton. for (let i = 0; i < fixedCount; i++) angles[i] = (i / fixedCount) * TAU; // Half chaos: seeded, never Math.random(). const rng = createRng(seed); for (let i = fixedCount; i < n; i++) angles[i] = rng() * TAU; raw.set(angles); const nodes: Node[] = Array.from({ length: n }, (unused, id) => ({ id, angle: angles[id] ?? 0, fixed: id < fixedCount, })); for (let pass = 0; pass < passes && n > 1; pass++) { // Re-sort every sweep. A push can carry a point across zero, which // re-orders the ring; a gap computed from stale neighbours pushes the // wrong point and leaves a permanent clump at the wrap. nodes.sort((a, b) => mod(a.angle, TAU) - mod(b.angle, TAU)); for (let i = 0; i < n; i++) { const a = nodes[i]; const b = nodes[(i + 1) % n]; if (!a || !b || a === b) continue; // mod() measures the gap the short way round, so the pair that straddles // zero needs no special case. const gap = mod(b.angle - a.angle, TAU); if (gap >= targetGap) continue; const deficit = (targetGap - gap) * pushShare; if (a.fixed && b.fixed) continue; // A pinned neighbour cannot absorb its share, so the free one takes all // of it. Otherwise points pile up against the lattice and never separate. if (a.fixed) b.angle += deficit * 2; else if (b.fixed) a.angle -= deficit * 2; else { a.angle -= deficit; b.angle += deficit; } } } for (const node of nodes) angles[node.id] = node.angle; const after = ringStats(angles, targetGap); const before = ringStats(raw, targetGap); return { angles, raw, minGap: after.minGap, rawMinGap: before.minGap, crowded: after.crowded, rawCrowded: before.crowded, targetGap, fixedCount, };}```## RequirementsThese are not optional. A version of this component that skips any of them isnot finished.**Cleanup.** `useRaf` unsubscribes on unmount and the shared frame is cancelled when the last subscriber leaves; the loop also unsubscribes itself the moment the morph completes, so a settled ring costs nothing. `useInView` unobserves the root. No timers, no listeners, no retained typed arrays beyond the memo.**Reduced motion.** The relaxed angles render immediately with no sweep. The dashed ghosts at the raw angles stay, so the before/after comparison the component exists to make is fully readable without anything moving, and the gap readout is unchanged.**Frame-rate independence.** Any smoothing must be a function of delta time.`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where`smoothing` is the fraction of the gap remaining after one second.**Strict mode.** Assume effects run twice in development. Setup must beidempotent and teardown must fully undo it.## Known pitfalls — avoid these specifically- `Math.random()` instead of a seeded generator. The server renders one arrangement and the client another, React reports a hydration mismatch and re-renders the subtree, and the layout is different on every reload so no visual diff is ever reviewable.- Not re-sorting between sweeps. A push can carry a point across zero, which re-orders the ring; the next sweep then measures the gap to a point that is no longer the neighbour, pushes the wrong one, and leaves a permanent clump sitting exactly on the wrap.- Measuring the gap as `next - current`. For the pair that straddles zero that is a large negative number, which is always below the target, so the relaxation shoves those two apart on every sweep forever and one point walks all the way around the ring.- Splitting the deficit evenly when one neighbour is pinned. The free point only ever gets half the correction it needs, so it converges to half the required gap and stays visibly stuck to the lattice tick.- Wrapping the angles into `[0, 2PI)` on the way out. The interpolation from `raw` to `angles` then sends any point that crossed zero the long way round the ring, and one tick sweeps a full circle during the settle while the rest move a few degrees.- Rebuilding the ring on every render. `relaxedRingAngles` is O(passes · n log n) and allocates; without `useMemo` keyed on the inputs, every parent render re-runs 15 sweeps and re-allocates two typed arrays.## Notes- Relaxing over the full ring with the lattice pinned is a deliberate deviation from the version this was taken from, which relaxed only the random subset against itself. That version lets a random point land exactly on a lattice point, which is the one collision the technique is supposed to prevent.- `relaxedRingAngles` is the shipped part. Feed the angles into whatever you are placing — CSS `rotate()`, an instanced attribute, a `THREE.Vector3` per item — the visualiser is only here to show what the numbers do.## Definition of doneOn first scroll into view the solid ticks slide off their dashed ghosts and stop; every accent tick stays exactly on its ghost because the lattice is pinned. The "min gap" readout shows a struck-through small number followed by a larger one at or above the target. Reload ten times and the arrangement is byte-identical. Set `evenRatio` to 0 and every tick moves.When you are finished, tell me which of the requirements above you implementedand where, so I can check them.
'use client';import { useMemo, useRef, useState } from 'react';import { cn } from '@/lib/utils';import { useInView } from '@/hooks/use-in-view';import { useRaf } from '@/hooks/use-raf';import { useReducedMotion } from '@/hooks/use-reduced-motion';import { easeOutQuart } from '@/lib/easing';import { createRng } from '@/lib/prng';import { mod } from '@/lib/wrap';/* ========================================================================== Relaxed Seeded Scatter -------------------------------------------------------------------------- Pure random angles clump: uniform sampling produces gaps and clusters, and the eye reads a cluster as a mistake. Pure even spacing reads as a machine. The composition everyone actually wants is half lattice, half noise, with the noise pushed apart until nothing collides — a couple of Lloyd relaxation passes over the circle. Seeded, so the server and the client agree and the layout is the same on every reload. `Math.random()` here is a hydration mismatch and an un-reviewable visual diff. ========================================================================== */const TAU = Math.PI * 2;export interface RelaxedRingOptions { /** Relaxation sweeps. Each one halves the remaining overlap. */ passes?: number; /** Minimum neighbour gap as a fraction of the mean gap `2PI / count`. */ minGapRatio?: number; /** Fraction of the points pinned to an exact lattice. */ evenRatio?: number; /** Share of a deficit each of the two neighbours absorbs. */ pushShare?: number;}export interface RelaxedRing { /** * Relaxed angles in radians, indexed by item. Deliberately NOT wrapped into * `[0, 2PI)`: a point pushed past zero keeps a small negative value so that * interpolating from `raw` to here never takes the long way round. `cos` and * `sin` do not care. */ angles: Float32Array; /** The same items before relaxation, same indices. */ raw: Float32Array; /** Smallest neighbour gap after relaxation, radians. */ minGap: number; /** Smallest neighbour gap before relaxation, radians. */ rawMinGap: number; /** Neighbour pairs closer than `targetGap`, after and before. */ crowded: number; rawCrowded: number; /** The gap the relaxation was aiming for. */ targetGap: number; /** Number of points pinned to the lattice. */ fixedCount: number;}// #region critical/** Sweeps. Each halves the residual overlap; 15 is fully converged for n<=64. */const RELAX_PASSES = 15;/** Floor on the neighbour gap, as a fraction of the mean gap. */const MIN_GAP_RATIO = 0.4;/** Half the points sit on an exact lattice and never move. */const EVEN_RATIO = 0.5;/** Each free neighbour absorbs half the deficit. */const PUSH_SHARE = 0.5;type Node = { id: number; angle: number; fixed: boolean };/** Smallest neighbour gap on the circle, and how many pairs are under `target`. */function ringStats(values: ArrayLike<number>, target: number) { const n = values.length; if (n < 2) return { minGap: TAU, crowded: 0 }; const sorted = Array.from(values, (v) => mod(v, TAU)).sort((a, b) => a - b); let minGap = TAU; let crowded = 0; for (let i = 0; i < n; i++) { const a = sorted[i] ?? 0; const b = sorted[(i + 1) % n] ?? 0; const gap = i === n - 1 ? b + TAU - a : b - a; if (gap < minGap) minGap = gap; if (gap < target) crowded++; } return { minGap, crowded };}export function relaxedRingAngles( count: number, seed: number | string, options: RelaxedRingOptions = {},): RelaxedRing { const { passes = RELAX_PASSES, minGapRatio = MIN_GAP_RATIO, evenRatio = EVEN_RATIO, pushShare = PUSH_SHARE, } = options; const n = Math.max(0, Math.floor(count)); const angles = new Float32Array(n); const raw = new Float32Array(n); const targetGap = n > 0 ? (TAU / n) * minGapRatio : 0; const fixedCount = Math.floor(n * evenRatio); if (n === 0) { return { angles, raw, minGap: 0, rawMinGap: 0, crowded: 0, rawCrowded: 0, targetGap, fixedCount }; } // Half order: an exact lattice, so the composition has a skeleton. for (let i = 0; i < fixedCount; i++) angles[i] = (i / fixedCount) * TAU; // Half chaos: seeded, never Math.random(). const rng = createRng(seed); for (let i = fixedCount; i < n; i++) angles[i] = rng() * TAU; raw.set(angles); const nodes: Node[] = Array.from({ length: n }, (unused, id) => ({ id, angle: angles[id] ?? 0, fixed: id < fixedCount, })); for (let pass = 0; pass < passes && n > 1; pass++) { // Re-sort every sweep. A push can carry a point across zero, which // re-orders the ring; a gap computed from stale neighbours pushes the // wrong point and leaves a permanent clump at the wrap. nodes.sort((a, b) => mod(a.angle, TAU) - mod(b.angle, TAU)); for (let i = 0; i < n; i++) { const a = nodes[i]; const b = nodes[(i + 1) % n]; if (!a || !b || a === b) continue; // mod() measures the gap the short way round, so the pair that straddles // zero needs no special case. const gap = mod(b.angle - a.angle, TAU); if (gap >= targetGap) continue; const deficit = (targetGap - gap) * pushShare; if (a.fixed && b.fixed) continue; // A pinned neighbour cannot absorb its share, so the free one takes all // of it. Otherwise points pile up against the lattice and never separate. if (a.fixed) b.angle += deficit * 2; else if (b.fixed) a.angle -= deficit * 2; else { a.angle -= deficit; b.angle += deficit; } } } for (const node of nodes) angles[node.id] = node.angle; const after = ringStats(angles, targetGap); const before = ringStats(raw, targetGap); return { angles, raw, minGap: after.minGap, rawMinGap: before.minGap, crowded: after.crowded, rawCrowded: before.crowded, targetGap, fixedCount, };}// #endregion criticalexport interface RelaxedSeededScatterProps { count?: number; seed?: number | string; passes?: number; minGapRatio?: number; evenRatio?: number; /** Ease from the raw angles to the relaxed ones the first time it is seen. */ morph?: boolean; className?: string;}/** Seconds for the raw -> relaxed sweep. */const MORPH_DURATION = 1.4;const RING_RADIUS = 33;const TICK_INNER = 27;const TICK_OUTER = 38;export function RelaxedSeededScatter({ count = 24, seed = 'kinetic', passes = RELAX_PASSES, minGapRatio = MIN_GAP_RATIO, evenRatio = EVEN_RATIO, morph = true, className,}: RelaxedSeededScatterProps) { const root = useRef<HTMLDivElement>(null); const groups = useRef<(SVGGElement | null)[]>([]); const elapsed = useRef(0); const [settled, setSettled] = useState(false); const reduced = useReducedMotion(); const { inView } = useInView(root, { threshold: 0.35, once: true }); const ring = useMemo( () => relaxedRingAngles(count, seed, { passes, minGapRatio, evenRatio }), [count, seed, passes, minGapRatio, evenRatio], ); const animating = morph && !reduced && inView && !settled; useRaf((dt) => { elapsed.current += dt; const t = easeOutQuart(Math.min(elapsed.current / MORPH_DURATION, 1)); for (let i = 0; i < count; i++) { const g = groups.current[i]; const from = ring.raw[i]; const to = ring.angles[i]; if (!g || from === undefined || to === undefined) continue; const angle = from + (to - from) * t; g.setAttribute('transform', `rotate(${((angle * 180) / Math.PI).toFixed(3)})`); } if (elapsed.current >= MORPH_DURATION) setSettled(true); }, animating); const fmt = (v: number) => v.toFixed(3); return ( <div ref={root} className={cn('flex h-full w-full flex-col', className)}> <div className="min-h-0 flex-1"> <svg viewBox="-50 -50 100 100" className="h-full w-full" aria-hidden> <circle r={RING_RADIUS} className="fill-none stroke-line" strokeWidth={0.3} /> {/* Where the seeded sampler put each point. Half of them collide. */} {Array.from({ length: count }, (unused, i) => { const a = ring.raw[i]; if (a === undefined || i < ring.fixedCount) return null; return ( <g key={`ghost-${i}`} transform={`rotate(${(a * 180) / Math.PI})`}> <line x1={0} y1={-TICK_INNER} x2={0} y2={-TICK_OUTER} className="stroke-faint" strokeWidth={0.35} strokeDasharray="1 1.4" /> </g> ); })} {/* Where relaxation put them. Pinned lattice points never left. */} {Array.from({ length: count }, (unused, i) => { const start = morph && !reduced ? ring.raw[i] : ring.angles[i]; const fixed = i < ring.fixedCount; if (start === undefined) return null; return ( <g key={i} ref={(el) => { groups.current[i] = el; }} transform={`rotate(${(start * 180) / Math.PI})`} > <line x1={0} y1={-TICK_INNER} x2={0} y2={-TICK_OUTER} className={fixed ? 'stroke-accent' : 'stroke-fg'} strokeWidth={fixed ? 0.7 : 0.5} /> <circle cy={-TICK_OUTER} r={fixed ? 1.5 : 1.1} className={fixed ? 'fill-accent' : 'fill-fg'} /> </g> ); })} </svg> </div> <dl className="grid shrink-0 grid-cols-3 gap-px border-t border-line bg-line font-mono text-[10px] uppercase tracking-[0.08em]"> <div className="bg-bg px-3 py-2"> <dt className="text-faint">min gap</dt> <dd className="mt-0.5 tabular-nums text-fg"> <span className="text-faint line-through">{fmt(ring.rawMinGap)}</span>{' '} {fmt(ring.minGap)} </dd> </div> <div className="bg-bg px-3 py-2"> <dt className="text-faint">target</dt> <dd className="mt-0.5 tabular-nums text-muted">{fmt(ring.targetGap)}</dd> </div> <div className="bg-bg px-3 py-2"> <dt className="text-faint">crowded</dt> <dd className="mt-0.5 tabular-nums text-fg"> <span className="text-faint line-through">{ring.rawCrowded}</span> {ring.crowded} </dd> </div> </dl> </div> );}
/* ========================================================================== Easing. -------------------------------------------------------------------------- Plain functions on [0,1] so a component can ease without pulling in GSAP, plus the matching `cubic-bezier()` strings for CSS. The two lists are the same curves, so a component can animate in JS and its reduced-motion fallback can animate in CSS without the timing changing character. ========================================================================== */export type EasingFn = (t: number) => number;export const linear: EasingFn = (t) => t;export const easeInQuad: EasingFn = (t) => t * t;export const easeOutQuad: EasingFn = (t) => 1 - (1 - t) * (1 - t);export const easeInOutQuad: EasingFn = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;export const easeInCubic: EasingFn = (t) => t * t * t;export const easeOutCubic: EasingFn = (t) => 1 - Math.pow(1 - t, 3);export const easeInOutCubic: EasingFn = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;export const easeInQuart: EasingFn = (t) => t * t * t * t;export const easeOutQuart: EasingFn = (t) => 1 - Math.pow(1 - t, 4);export const easeInOutQuart: EasingFn = (t) => t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;export const easeInExpo: EasingFn = (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10));export const easeOutExpo: EasingFn = (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));export const easeInOutExpo: EasingFn = (t) => t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;/** Overshoots past 1 then settles. `s` is the overshoot amount. */export const backOut = (s = 1.70158): EasingFn => { const c = s + 1; return (t) => 1 + c * Math.pow(t - 1, 3) + s * Math.pow(t - 1, 2);};export const elasticOut: EasingFn = (t) => { const c = (2 * Math.PI) / 3; return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;};/** CSS equivalents of the curves above, for reduced-motion fallbacks. */export const CSS_EASING = { outQuad: 'cubic-bezier(0.5, 1, 0.89, 1)', outCubic: 'cubic-bezier(0.33, 1, 0.68, 1)', outQuart: 'cubic-bezier(0.25, 1, 0.5, 1)', outExpo: 'cubic-bezier(0.16, 1, 0.3, 1)', inOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)', inOutExpo: 'cubic-bezier(0.87, 0, 0.13, 1)',} as const;
/* ========================================================================== Seeded randomness. -------------------------------------------------------------------------- `Math.random()` in a particle field means the server and the client disagree, which React reports as a hydration mismatch, and it means the layout is different on every reload so a visual regression is invisible. Every component that scatters something uses a seeded generator instead. ========================================================================== */export type Rng = () => number;/** * mulberry32 — 32-bit, fast, and good enough for visual scatter. Returns a * function yielding floats in [0, 1). */export function mulberry32(seed: number): Rng { let a = seed >>> 0; return function next() { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}/** Turn any string into a 32-bit seed, so `seed="hero"` is reproducible. */export function hashSeed(input: string): number { let h = 2166136261 >>> 0; for (let i = 0; i < input.length; i++) { h ^= input.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0;}export function createRng(seed: number | string): Rng { return mulberry32(typeof seed === 'string' ? hashSeed(seed) : seed);}export function randRange(rng: Rng, min: number, max: number): number { return min + rng() * (max - min);}export function randInt(rng: Rng, min: number, maxExclusive: number): number { return Math.floor(randRange(rng, min, maxExclusive));}/** * Uniform point on a unit sphere. Naive angle-pairing clusters at the poles; * this inverts the cosine so density is even. */export function randOnSphere(rng: Rng): [number, number, number] { const u = rng() * 2 - 1; const theta = rng() * Math.PI * 2; const r = Math.sqrt(1 - u * u); return [r * Math.cos(theta), r * Math.sin(theta), u];}/** Uniform point in a unit disc — `sqrt` prevents crowding at the centre. */export function randInDisc(rng: Rng): [number, number] { const r = Math.sqrt(rng()); const theta = rng() * Math.PI * 2; return [r * Math.cos(theta), r * Math.sin(theta)];}/** In-place Fisher-Yates using the supplied generator. */export function shuffle<T>(rng: Rng, arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = randInt(rng, 0, i + 1); const a = arr[i] as T; const b = arr[j] as T; arr[i] = b; arr[j] = a; } return arr;}
'use client';import { useEffect, useRef, useState, type RefObject } from 'react';/* ========================================================================== IntersectionObserver, sharing one observer per option set. -------------------------------------------------------------------------- A hundred cards each constructing their own observer is a hundred observers the compositor has to service. Keying a shared observer by its options collapses that to one per distinct configuration — in practice, two or three for the whole site. ========================================================================== */export interface InViewState { inView: boolean; /** 0-1. The preview slot allocator ranks candidates by this. */ ratio: number;}export interface UseInViewOptions { root?: Element | null; rootMargin?: string; /** Single number or list, same meaning as the native option. */ threshold?: number | number[]; /** Stop observing after the first time it becomes visible. */ once?: boolean;}type Entry = { observer: IntersectionObserver; handlers: Map<Element, (e: IntersectionObserverEntry) => void>;};const pools = new Map<string, Entry>();function poolFor(options: UseInViewOptions): Entry { const key = JSON.stringify({ rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0, }); const existing = pools.get(key); if (existing) return existing; const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>(); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) handlers.get(entry.target)?.(entry); }, { root: options.root ?? null, rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0, }, ); const created: Entry = { observer, handlers }; pools.set(key, created); return created;}export function useInView( ref: RefObject<Element | null>, options: UseInViewOptions = {},): InViewState { const [state, setState] = useState<InViewState>({ inView: false, ratio: 0 }); const { root = null, rootMargin = '0px', once = false } = options; const thresholdKey = JSON.stringify(options.threshold ?? 0); const done = useRef(false); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === 'undefined') return; if (once && done.current) return; const pool = poolFor({ root, rootMargin, threshold: JSON.parse(thresholdKey) as number | number[], }); const handle = (entry: IntersectionObserverEntry) => { setState({ inView: entry.isIntersecting, ratio: entry.intersectionRatio }); if (once && entry.isIntersecting) { done.current = true; pool.handlers.delete(el); pool.observer.unobserve(el); } }; pool.handlers.set(el, handle); pool.observer.observe(el); return () => { pool.handlers.delete(el); pool.observer.unobserve(el); }; }, [ref, root, rootMargin, thresholdKey, once]); return state;}
'use client';import { useEffect, useRef } from 'react';/* ========================================================================== One shared requestAnimationFrame loop. -------------------------------------------------------------------------- Forty components each calling requestAnimationFrame means forty callbacks the browser schedules separately and forty chances to read layout after a write. One loop, one ordered pass, one place to clamp delta time. Delta is clamped to 100 ms. Without that, returning to a backgrounded tab delivers a multi-second dt and every physics integration in the page explodes on the first frame back. ========================================================================== */export type RafCallback = (dt: number, elapsed: number) => void;const callbacks = new Set<RafCallback>();let frame = 0;let last = 0;let start = 0;const MAX_DT = 0.1;const DEFAULT_DT = 1 / 60;function tick(now: number) { frame = requestAnimationFrame(tick); const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT); last = now; const elapsed = (now - start) / 1000; // Copy: a callback may unsubscribe itself (or another) mid-iteration. for (const cb of Array.from(callbacks)) { if (callbacks.has(cb)) cb(dt, elapsed); }}function subscribe(cb: RafCallback) { callbacks.add(cb); if (frame === 0) { start = performance.now(); last = 0; frame = requestAnimationFrame(tick); } return () => { callbacks.delete(cb); if (callbacks.size === 0 && frame !== 0) { cancelAnimationFrame(frame); frame = 0; last = 0; } };}/** * Run `cb` every frame while `enabled`. * * The callback is held in a ref, so it does not need to be memoised and the * subscription is never torn down just because the closure changed identity. */export function useRaf(cb: RafCallback, enabled = true): void { const ref = useRef(cb); ref.current = cb; useEffect(() => { if (!enabled) return; return subscribe((dt, elapsed) => ref.current(dt, elapsed)); }, [enabled]);}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== prefers-reduced-motion, SSR-safe. -------------------------------------------------------------------------- useSyncExternalStore rather than useState + useEffect: the server snapshot is an explicit `false`, so the markup React renders on the server and the markup it hydrates with agree, and the real value lands in the same commit as hydration instead of one frame later. A useEffect version flashes the animated variant before removing it — the exact thing the user asked not to see. Reduced motion is not "duration: 0". For most components here it means a different render: the final state, a static poster, or a paused frame. ========================================================================== */const QUERY = '(prefers-reduced-motion: reduce)';function subscribe(onChange: () => void) { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);}function getSnapshot() { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(QUERY).matches;}function getServerSnapshot() { return false;}export function useReducedMotion(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}/** Generic media-query hook, same SSR guarantees. */export function useMediaQuery(query: string): boolean { return useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(query); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(query).matches; }, () => false, );}
/* ========================================================================== Ring index arithmetic. -------------------------------------------------------------------------- Anything that loops — a drum carousel, an infinite marquee, a cylinder of image planes — needs to answer "how far is item i from position p" in a way that treats the sequence as a circle. A plain `i - p` says item 0 is 18 away from item 18 in a list of 19, when really it is 1 step back. ========================================================================== *//** True modulo. Unlike `%`, the result is never negative. */export function mod(n: number, m: number): number { return ((n % m) + m) % m;}/** * Shortest signed distance from `position` to index `i` around a ring of * `count` items. Result is in `(-count/2, count/2]`; negative means "behind". * * This one function is what makes a finite array behave like an infinite one. */export function wrappedOffset(i: number, position: number, count: number): number { if (count <= 0) return 0; let offset = mod(i - position, count); if (offset > count / 2) offset -= count; return offset;}/** Wrap `value` into `[min, max)`, e.g. an angle into `[-PI, PI)`. */export function wrapRange(value: number, min: number, max: number): number { const span = max - min; if (span <= 0) return min; return mod(value - min, span) + min;}/** Wrap an index into `[0, count)`. Accepts fractional and negative input. */export function wrapIndex(i: number, count: number): number { return count <= 0 ? 0 : mod(i, count);}/** * Shortest signed angular distance from `a` to `b` in radians, in `(-PI, PI]`. * Prevents a rotation tween from taking the long way round. */export function shortestAngle(a: number, b: number): number { return wrapRange(b - a, -Math.PI, Math.PI);}
05
API
Every parameter, its type and what happens when you change it.
function relaxedRingAngles(count: number, seed: number | string, options?: { passes?: number; minGapRatio?: number; evenRatio?: number; pushShare?: number }): RelaxedRing;function RelaxedSeededScatter(props: { count?: number; seed?: number | string; passes?: number; minGapRatio?: number; evenRatio?: number; morph?: boolean; className?: string }): JSX.Element
Parameters
Parameter
Type
Default
Description
count
number
= 24
Points on the ring.
seed
number | string
= 'kinetic'
Fixed seed. Strings are hashed with hashSeed.
passes
number
= 15
Relaxation sweeps.
minGapRatio
number
= 0.4
Minimum neighbour gap as a fraction of the mean spacing.
evenRatio
number
= 0.5
Fraction of points pinned to an exact lattice.
morph
boolean
= true
Ease from the raw angles to the relaxed ones on first view.
className
string
—
Merged onto the outer element via cn.
06
How it works
The technique in order, not the code in order.
01
floor(count * evenRatio) points are written at (i / fixedCount) * 2PI and flagged fixed; the remainder get rng() * 2PI from createRng(seed), a mulberry32 stream. The raw array is copied before anything moves so the two states can be compared and interpolated.
02
The target gap is (2PI / count) * 0.4 — 40% of the mean spacing on the full ring, not on the random subset, so the floor means the same thing regardless of how many points are pinned.
03
Each sweep sorts the nodes by mod(angle, 2PI), then walks consecutive pairs measuring gap = mod(next.angle - current.angle, 2PI). Because mod always measures the short way round, the pair that straddles zero needs no special case.
04
When a gap is under target, each free endpoint is moved by (target - gap) * 0.5 away from the other; if one endpoint is pinned to the lattice the free one absorbs the whole (target - gap) instead, or points pile up against the lattice and never separate.
05
Angles are stored unwrapped — a point pushed below zero keeps a small negative value rather than jumping to just under 2PI — so the component can interpolate raw -> relaxed linearly and never take the long way round. Only the sort and the gap measurement wrap.
06
The visualiser eases that interpolation over 1.4 s with easeOutQuart on the shared rAF loop, writing the transform attribute of one <g> per point. The SVG uses a -50 -50 100 100 viewBox, so it resizes with its container and needs no ResizeObserver.
Load-bearing constants
Load-bearing constants
Constant
Value
Why this number
RELAX_PASSES
= 15
Each sweep removes about half the remaining overlap, so 15 leaves 2^-15 of the initial error — fully converged for any count up to ~64. At 3 passes the tightest pairs are still visibly paired; past ~20 nothing measurable changes and you are paying O(passes · n log n) for nothing.
MIN_GAP_RATIO
= 0.4
Floor on the neighbour gap as a fraction of the mean, so targetGap = 0.4 * 2PI / count. Below ~0.25 clusters survive and the scatter still reads as clumped. Above ~0.6 the relaxation cannot satisfy every pair at once, so it oscillates between passes and converges toward the lattice — which throws away the randomness you asked for.
EVEN_RATIO
= 0.5
Fraction pinned to the exact lattice. At 0 the composition has no skeleton and the eye finds no structure; at 1 it is a clock face. Half and half is the mix that reads as deliberate but not mechanical.
PUSH_SHARE
= 0.5
Each free neighbour absorbs half the deficit, so a pair separates by exactly the deficit per sweep. At 1.0 both move the full amount, overshoot, and the pair oscillates instead of settling.
MORPH_DURATION
= 1.4
Seconds for the raw-to-relaxed sweep. Long enough to follow individual points leaving their ghosts; under ~0.6 s it reads as a single cut and the comparison is lost.
seed
= 'kinetic'
Any fixed value works — what matters is that it is fixed. hashSeed turns the string into a 32-bit integer, so a named seed per instance gives every ring its own reproducible composition.
07
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The relaxed angles render immediately with no sweep. The dashed ghosts at the raw angles stay, so the before/after comparison the component exists to make is fully readable without anything moving, and the gap readout is unchanged.
Cleanup
useRaf unsubscribes on unmount and the shared frame is cancelled when the last subscriber leaves; the loop also unsubscribes itself the moment the morph completes, so a settled ring costs nothing. useInView unobserves the root. No timers, no listeners, no retained typed arrays beyond the memo.
Definition of done
On first scroll into view the solid ticks slide off their dashed ghosts and stop; every accent tick stays exactly on its ghost because the lattice is pinned. The "min gap" readout shows a struck-through small number followed by a larger one at or above the target. Reload ten times and the arrangement is byte-identical. Set evenRatio to 0 and every tick moves.
Pitfalls
Math.random() instead of a seeded generator. The server renders one arrangement and the client another, React reports a hydration mismatch and re-renders the subtree, and the layout is different on every reload so no visual diff is ever reviewable.
Not re-sorting between sweeps. A push can carry a point across zero, which re-orders the ring; the next sweep then measures the gap to a point that is no longer the neighbour, pushes the wrong one, and leaves a permanent clump sitting exactly on the wrap.
Measuring the gap as next - current. For the pair that straddles zero that is a large negative number, which is always below the target, so the relaxation shoves those two apart on every sweep forever and one point walks all the way around the ring.
Splitting the deficit evenly when one neighbour is pinned. The free point only ever gets half the correction it needs, so it converges to half the required gap and stays visibly stuck to the lattice tick.
Wrapping the angles into [0, 2PI) on the way out. The interpolation from raw to angles then sends any point that crossed zero the long way round the ring, and one tick sweeps a full circle during the settle while the rest move a few degrees.
Rebuilding the ring on every render. relaxedRingAngles is O(passes · n log n) and allocates; without useMemo keyed on the inputs, every parent render re-runs 15 sweeps and re-allocates two typed arrays.
Notes
Relaxing over the full ring with the lattice pinned is a deliberate deviation from the version this was taken from, which relaxed only the random subset against itself. That version lets a random point land exactly on a lattice point, which is the one collision the technique is supposed to prevent.
relaxedRingAngles is the shipped part. Feed the angles into whatever you are placing — CSS rotate(), an instanced attribute, a THREE.Vector3 per item — the visualiser is only here to show what the numbers do.