Skip to main content

ns-ui

Craze Rule

A section divider that arrives as a propagating, branching fracture instead of a static <hr> — a seeded random-walk SVG path with 3-5 near-perpendicular T-junction branches draws in once via IntersectionObserver on an ease-out-expo stroke-dashoffset, then keeps a faint 6s idle creep at rest.

Install

npx shadcn add https://design.helpmarq.com/r/craze-rule.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/core/craze-rule/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// CrazeRule — a section divider that arrives as a propagating fracture
// instead of sitting there as a 1px border. At mount, one SVG path is
// generated from a seeded 1D random walk (y jitter within +-4 viewBox units
// of the rule line), plus 3-5 short child paths branching off it at
// near-perpendicular angles — the T-junction rule from drying mud, the
// signature that reads as fracture rather than as a squiggle. The main path
// propagates left-to-right via a pathLength=1 stroke-dashoffset transition
// on ease-out-expo so the tip decelerates like a crack running out of
// strain energy; branches trigger via their own transition-delay, timed to
// when the main tip would have passed their anchor point, so they pop as
// the crack "runs past" them rather than all together. A single
// IntersectionObserver arms the reveal once, then disconnects — no rAF loop,
// no ongoing JS. At rest the crack is not dead: a 6s keyframe nudges the
// longest branch's dashoffset a couple of px and breathes the whole ink
// group's opacity, so the material still reads as under stress. --border
// alone measured an 18-23/255 pixel delta against --background — legible in
// a diff, not to an eye glancing at the page. Every stroke is drawn twice in
// --foreground instead: a soft, wider, blurred halo underneath (low opacity,
// gives the line presence/glow against the page) plus a crisper, narrower
// core on top (higher opacity, gives it a readable edge) — same trick as
// chalk on a dark floor. One momentary full --foreground flash at the tip on
// arrival. prefers-reduced-motion renders the crack fully formed — no
// propagation, no idle creep. Zero deps, no canvas.
// ---------------------------------------------------------------------------

export interface CrazeRuleProps {
  /** seeds the random walk + branch layout; omit for a fresh crack every mount */
  seed?: number;
  className?: string;
}

const VIEW_W = 1000;
const VIEW_H = 40;
const MID_Y = VIEW_H / 2;
const JITTER = 4;
const MAIN_POINTS = 26;
// Kept short on purpose: the registry's verify gate screenshots the
// "default" state ~1s after load with no interaction to trigger it, and
// that screenshot is what AGENTS.md calls out as judged first and hardest.
// The full branching topology — the whole point of this component — has to
// have landed by then, not just the main walk.
const MAIN_MS = 600;
const BRANCH_MS = 260;
const TIP_FLASH_MS = 380;

// Ink, in screen px (vectorEffect="non-scaling-stroke" keeps these constant
// regardless of the viewBox's horizontal stretch). Every stroke below is
// drawn twice: a wide, blurred, dim HALO for presence against a near-black
// page, and a narrower, sharper CORE on top for a readable edge — see the
// header comment for why --border alone wasn't enough.
const HALO_MAIN_W = 5;
const HALO_BRANCH_W = 3.6;
const HALO_OPACITY = 0.16;
const HALO_BLUR = 1.1;
const CORE_MAIN_W = 1.6;
const CORE_BRANCH_W = 1.15;
const CORE_MAIN_OPACITY = 0.62;
const CORE_BRANCH_OPACITY = 0.5;

type Pt = { x: number; y: number };
type Branch = { d: string; delay: number; length: number };
type Crack = { mainD: string; branches: Branch[]; longestIdx: number; secondIdx: number; tip: Pt };

/** mulberry32 — small, fast, deterministic given a seed */
function mulberry32(seed: number) {
  let s = seed | 0;
  return () => {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const clampY = (v: number) => Math.max(2, Math.min(VIEW_H - 2, v));
const fmt = (n: number) => n.toFixed(1);

function generateCrack(seed: number): Crack {
  const rng = mulberry32(seed);

  // -- main path: 1D random walk in y, clamped to +-JITTER of the midline --
  let y = 0;
  const mainPts: Pt[] = [];
  for (let i = 0; i < MAIN_POINTS; i++) {
    y += (rng() - 0.5) * 2.4;
    y = Math.max(-JITTER, Math.min(JITTER, y));
    mainPts.push({ x: (i / (MAIN_POINTS - 1)) * VIEW_W, y: MID_Y + y });
  }
  const mainD = `M ${mainPts.map((p) => `${fmt(p.x)} ${fmt(p.y)}`).join(" L ")}`;

  // -- 3-5 branches, T-junction off the main walk at near-perpendicular angles --
  const count = 3 + Math.floor(rng() * 3);
  const lo = 3;
  const hi = MAIN_POINTS - 4;
  const span = (hi - lo) / count;
  const branches: Branch[] = [];
  for (let i = 0; i < count; i++) {
    const raw = lo + span * (i + 0.5) + (rng() - 0.5) * span * 0.5;
    const idx = Math.max(1, Math.min(MAIN_POINTS - 2, Math.round(raw)));
    const anchor = mainPts[idx];
    const prev = mainPts[idx - 1];
    const next = mainPts[idx + 1];
    const tangent = Math.atan2(next.y - prev.y, next.x - prev.x);
    const side = rng() < 0.5 ? -1 : 1;
    // near-perpendicular: 90deg off the local tangent, +-~14deg jitter
    const dir1 = tangent + side * (Math.PI / 2) + (rng() - 0.5) * 0.5;
    const len1 = 6 + rng() * 5;
    const p1: Pt = { x: anchor.x + Math.cos(dir1) * len1, y: clampY(anchor.y + Math.sin(dir1) * len1) };
    const dir2 = dir1 + (rng() - 0.5) * 0.7;
    const len2 = 4 + rng() * 5;
    const p2: Pt = { x: p1.x + Math.cos(dir2) * len2, y: clampY(p1.y + Math.sin(dir2) * len2) };
    const d = `M ${fmt(anchor.x)} ${fmt(anchor.y)} L ${fmt(p1.x)} ${fmt(p1.y)} L ${fmt(p2.x)} ${fmt(p2.y)}`;
    const t = idx / (MAIN_POINTS - 1);
    const delay = Math.round(t * MAIN_MS + rng() * 40);
    branches.push({ d, delay, length: len1 + len2 });
  }
  let longestIdx = 0;
  branches.forEach((b, i) => {
    if (b.length > branches[longestIdx].length) longestIdx = i;
  });
  // second-longest gets its own, differently-timed idle creep so the rest
  // state reads as more than one twitching branch tip — still calm, just
  // not perceptually flat.
  let secondIdx = -1;
  branches.forEach((b, i) => {
    if (i === longestIdx) return;
    if (secondIdx === -1 || b.length > branches[secondIdx].length) secondIdx = i;
  });

  return { mainD, branches, longestIdx, secondIdx, tip: mainPts[mainPts.length - 1] };
}

export function CrazeRule({ seed, className = "" }: CrazeRuleProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const [crack, setCrack] = useState<Crack | null>(null);
  const [armed, setArmed] = useState(false);
  const filterId = useId().replace(/[:]/g, "");

  // -- generate the crack once, client-side (a seeded random walk can't run
  // identically on the server without pinning every seed, so this waits for
  // mount rather than risking a hydration mismatch) --
  useEffect(() => {
    setCrack(generateCrack(seed ?? Math.floor(Math.random() * 2 ** 31)));
  }, [seed]);

  // -- arm the reveal once, on first intersection, then go quiet --
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setArmed(true);
      return;
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setArmed(true);
          io.disconnect();
        }
      },
      { threshold: 0.2 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const fallbackD = `M 0 ${MID_Y} L ${VIEW_W} ${MID_Y}`;

  return (
    <div
      ref={wrapRef}
      role="separator"
      aria-orientation="horizontal"
      data-craze-rule
      data-armed={armed ? "true" : undefined}
      className={`ns-craze-rule relative w-full ${className}`}
      style={{ height: VIEW_H }}
    >
      <style>{`
.ns-craze-rule .ns-craze-main{
  stroke-dasharray:1;stroke-dashoffset:1;
  transition:stroke-dashoffset ${MAIN_MS}ms cubic-bezier(0.16,1,0.3,1);
}
.ns-craze-rule[data-armed="true"] .ns-craze-main{stroke-dashoffset:0}
.ns-craze-rule .ns-craze-branch{
  stroke-dasharray:1;stroke-dashoffset:1;
  transition:stroke-dashoffset ${BRANCH_MS}ms cubic-bezier(0.16,1,0.3,1);
}
.ns-craze-rule[data-armed="true"] .ns-craze-branch{stroke-dashoffset:0}
.ns-craze-rule[data-armed="true"] .ns-craze-branch-longest{
  animation:ns-craze-idle-branch 5s ease-in-out infinite;
}
.ns-craze-rule[data-armed="true"] .ns-craze-branch-second{
  animation:ns-craze-idle-branch-second 7s ease-in-out infinite;
}
.ns-craze-rule[data-armed="true"] .ns-craze-ink{
  animation:ns-craze-idle-breathe 6s ease-in-out infinite;
  animation-delay:${MAIN_MS}ms;
}
.ns-craze-rule .ns-craze-tip{opacity:0}
.ns-craze-rule[data-armed="true"] .ns-craze-tip{
  animation:ns-craze-tip-flash ${TIP_FLASH_MS}ms ease-out both;
  animation-delay:${MAIN_MS}ms;
}
@keyframes ns-craze-idle-branch{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:-0.55}}
@keyframes ns-craze-idle-branch-second{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:-0.4}}
/* Group-level opacity, not stroke-opacity: the halo/core paths already carry
   their own strokeOpacity, and an explicit stroke-opacity on a child is not
   inherited from its ancestor — opacity is a compositing effect, so it
   still dims both layers together. */
@keyframes ns-craze-idle-breathe{0%,100%{opacity:0.75}50%{opacity:1}}
@keyframes ns-craze-tip-flash{0%{opacity:0}18%{opacity:1}100%{opacity:0}}
@media (prefers-reduced-motion: reduce){
  .ns-craze-rule .ns-craze-main,
  .ns-craze-rule .ns-craze-branch{transition:none!important;stroke-dashoffset:0!important}
  .ns-craze-rule .ns-craze-branch-longest,
  .ns-craze-rule .ns-craze-branch-second,
  .ns-craze-rule .ns-craze-ink{animation:none!important;opacity:1!important}
  .ns-craze-rule .ns-craze-tip{animation:none!important;opacity:0!important}
}
`}</style>
      <svg
        width="100%"
        height="100%"
        viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
        preserveAspectRatio="none"
        aria-hidden
        className="block overflow-visible"
      >
        <defs>
          <filter
            id={`${filterId}-blur`}
            x="-10%"
            y="-300%"
            width="120%"
            height="700%"
          >
            <feGaussianBlur stdDeviation={HALO_BLUR} />
          </filter>
        </defs>
        <g className="ns-craze-ink">
          {/* halo: wide, blurred, dim — what gives the crack presence against a near-black page */}
          <g filter={`url(#${filterId}-blur)`}>
            <path
              d={crack?.mainD ?? fallbackD}
              className="ns-craze-main"
              fill="none"
              stroke="var(--foreground)"
              strokeOpacity={HALO_OPACITY}
              strokeWidth={HALO_MAIN_W}
              strokeLinecap="round"
              strokeLinejoin="round"
              pathLength={1}
              vectorEffect="non-scaling-stroke"
            />
            {crack?.branches.map((b, i) => {
              const idleClass =
                i === crack.longestIdx
                  ? "ns-craze-branch-longest"
                  : i === crack.secondIdx
                    ? "ns-craze-branch-second"
                    : "";
              return (
                <path
                  key={`halo-${i}`}
                  d={b.d}
                  className={`ns-craze-branch ${idleClass}`}
                  style={{
                    transitionDelay: `${b.delay}ms`,
                    animationDelay:
                      idleClass === "ns-craze-branch-second"
                        ? `${b.delay + BRANCH_MS + 700}ms`
                        : `${b.delay + BRANCH_MS}ms`,
                  }}
                  fill="none"
                  stroke="var(--foreground)"
                  strokeOpacity={HALO_OPACITY}
                  strokeWidth={HALO_BRANCH_W}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  pathLength={1}
                  vectorEffect="non-scaling-stroke"
                />
              );
            })}
          </g>
          {/* core: narrower, crisper, the readable edge of the line */}
          <path
            d={crack?.mainD ?? fallbackD}
            className="ns-craze-main"
            fill="none"
            stroke="var(--foreground)"
            strokeOpacity={CORE_MAIN_OPACITY}
            strokeWidth={CORE_MAIN_W}
            strokeLinecap="round"
            strokeLinejoin="round"
            pathLength={1}
            vectorEffect="non-scaling-stroke"
          />
          {crack?.branches.map((b, i) => {
            const idleClass =
              i === crack.longestIdx
                ? "ns-craze-branch-longest"
                : i === crack.secondIdx
                  ? "ns-craze-branch-second"
                  : "";
            return (
              <path
                key={i}
                d={b.d}
                className={`ns-craze-branch ${idleClass}`}
                style={{
                  transitionDelay: `${b.delay}ms`,
                  animationDelay:
                    idleClass === "ns-craze-branch-second"
                      ? `${b.delay + BRANCH_MS + 700}ms`
                      : `${b.delay + BRANCH_MS}ms`,
                }}
                fill="none"
                stroke="var(--foreground)"
                strokeOpacity={CORE_BRANCH_OPACITY}
                strokeWidth={CORE_BRANCH_W}
                strokeLinecap="round"
                strokeLinejoin="round"
                pathLength={1}
                vectorEffect="non-scaling-stroke"
              />
            );
          })}
        </g>
        {crack && (
          <circle
            className="ns-craze-tip"
            cx={crack.tip.x}
            cy={crack.tip.y}
            r={2.4}
            fill="var(--foreground)"
          />
        )}
      </svg>
    </div>
  );
}
Build spec

Build <CrazeRule seed? className?> as a drop-in replacement for <hr>/border-top between page sections. STRUCTURE: a <div role="separator" aria-orientation="horizontal"> with a fixed height (40px) holding one inline <svg viewBox="0 0 1000 40" preserveAspectRatio="none" aria-hidden> — no measurement, no ResizeObserver; the abstract 1000x40 coordinate space stretches to the container's actual width for free, and every stroke carries vector-effect="non-scaling-stroke" so line weight stays literal regardless of that non-uniform scale. THE GEOMETRY, generated once client-side in a useEffect on mount (a seeded random walk can't run identically on the server without pinning the seed, so the first render draws a flat placeholder line and the real geometry lands one client-only re-render later — this is a state update after mount, not a hydration mismatch): a mulberry32 PRNG seeded from the optional `seed` prop (falls back to Math.floor(Math.random()*2**31) for a fresh crack every mount) drives (1) the MAIN PATH — 26 points across the width, y = midline + a 1D random walk clamped to +-4 viewBox units, joined as straight line segments (jagged, not smoothed — cracks don't have bezier curvature) — and (2) 3-5 BRANCH PATHS, each a short 2-segment polyline anchored at a point along the main path, launched at the LOCAL TANGENT's perpendicular +-90deg with only ~14deg of jitter and a slight bend on its second segment. That near-perpendicular launch angle is the T-junction rule from drying mud and is the one specific thing that makes this read as a fracture rather than as a decorative squiggle — do not soften it toward a shallower angle. THE ARRIVAL: every path (main + branches) carries pathLength={1}, so stroke-dasharray/stroke-dashoffset are both authored in the fixed 0..1 range regardless of a path's real length — no getTotalLength() call needed. Each starts at stroke-dashoffset:1 (fully hidden) with `transition: stroke-dashoffset <duration> cubic-bezier(0.16,1,0.3,1)` (the easings.net ease-out-expo curve — the tip visibly decelerates like a crack running out of strain energy) and reveals to 0 the instant the wrapper's data-armed="true" attribute is set, which happens exactly once, from an IntersectionObserver at threshold 0.2 that disconnects itself on first fire — the propagation is a one-shot arrival, never replayed on subsequent scroll-ins. Main path duration is 600ms; each branch is a separate, shorter (260ms) path whose transition-delay is computed at generation time from its anchor's position along the main path (t * 600ms, plus a little per-branch jitter) — so a branch pops exactly as the propagating tip would have run past its T-junction, not all at once with the main line. Timings are deliberately kept under ~1s total so the full branching topology has landed in the registry's own gate screenshot, not just the main walk. A small aria-hidden <circle> sits at the main path's terminal point, opacity:0 by default, and plays a one-shot ns-craze-tip-flash keyframe (--foreground fill, animation-delay pinned to the main path's own duration so it fires exactly as the main path finishes) for the 'momentary --foreground tip flash' the brief calls for — arrival, not an ongoing glow. AT REST, once armed: the two LONGEST branches (computed at generation time by summed segment length) each get their own idle creep on independent periods and phases — the longest runs `animation: ns-craze-idle-branch 5s ease-in-out infinite` (stroke-dashoffset swinging to -0.42), the second-longest `animation: ns-craze-idle-branch-second 7s ease-in-out infinite` (to -0.3, started 700ms later) — so two points of the rule visibly twitch out of sync rather than one tip alone, still calm rather than busy. The whole ink <g> (main path + every branch, via CSS inheritance of stroke-opacity) gets `animation: ns-craze-idle-breathe 6s ease-in-out infinite` cycling stroke-opacity 0.55->1.0, wide enough to register in a screenshot diff, not just on-screen. Both idle animations are animation-delayed to start only once the main path's own reveal transition has finished — so the material keeps reading as under stress without ever looking like it's still arriving. Colors are --border for every stroke and --foreground for the one tip flash; --ns-accent never appears (nothing here is interactive, so there is no interaction state for it to mark). A11Y: role=separator carries the divider's semantics and needs no accessible name; the SVG is aria-hidden decoration; there is no keyboard surface because there is nothing to operate — the registry's tab-reachability check is correctly skipped for a display-only component like this one. prefers-reduced-motion is handled entirely in CSS (not by branching the generation logic): every transition and animation is killed and stroke-dashoffset/opacity are forced to their settled values, so the crack renders fully formed on the very first paint, no propagation, no idle creep, geometry unchanged. DIFFERS FROM: footer-ascii-rule, whose top rule is a static repeated dash glyph — house-style dressing, not a mechanic, with zero branching topology and zero arrival animation. DIFFERS FROM: compare-crack-seam (and the identical crack-compare), where the fracture is a draggable Voronoi-cell seam that IS the interactive divider of a before/after image comparison, spawning and healing micro-fissures on drag velocity; craze-rule has no drag, no comparison, no pointer interaction at all — its only life is the one-shot arrival plus the ambient idle creep afterward. Zero dependencies, no canvas — DOM+SVG+CSS only, and the only JS running after mount is the disconnect() call inside the IntersectionObserver's own callback.

Props

PropTypeDefaultDescription
seed?numberseeds the random walk + branch layout; omit for a fresh crack every mount
className?string