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.

Usage

registry/core/craze-rule/demo.tsx
"use client";

import { CrazeRule } from "./component";

// A product page: sections that would otherwise sit under a plain <hr> or
// border-top, but the seam between them arrives as a running fracture the
// instant the divider comes on screen, then keeps a faint idle creep going
// at rest — never a static rule.
export default function CrazeRuleDemo() {
  return (
    <main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / craze-rule
      </p>

      <section className="mt-6">
        <h1 className="text-2xl font-semibold text-foreground sm:text-3xl">
          Built for cold storage
        </h1>
        <p className="mt-3 text-sm leading-relaxed text-ns-muted">
          Every archive is written once, checksummed, and replicated across
          three regions before the upload confirms — retrieval is the only
          operation that ever touches it again.
        </p>
      </section>

      <CrazeRule className="my-10" />

      <section>
        <h2 className="text-lg font-semibold text-foreground">
          Retrieval, on your schedule
        </h2>
        <p className="mt-3 text-sm leading-relaxed text-ns-muted">
          Standard restores land in under twelve hours. Bulk restores queue
          behind nothing — request the whole vault and it starts moving the
          same minute.
        </p>
      </section>

      <CrazeRule className="my-10" />

      <section>
        <h2 className="text-lg font-semibold text-foreground">Pricing</h2>
        <p className="mt-3 text-sm leading-relaxed text-ns-muted">
          $0.004/GB-month to store, no minimum retention. Egress and restore
          requests are billed separately and shown before you confirm.
        </p>
      </section>
    </main>
  );
}

Component

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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/craze-rule.tsx, so point the import there.

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.

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, 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?number—seeds the random walk + branch layout; omit for a fresh crack every mount
className?string——