ns-ui
Divider Telephone Cord Delam
A full-width section divider modeled on thin-film delamination buckling, a compressive-stress film debonding from its substrate and popping upward into the real 'telephone cord' morphology: a roughly constant-width ridge that telescopes side to side as its failure front advances and periodically re-forks, never a straight blister.
Use when Pick divider-telephone-cord-delam when the divider's mechanic is a compressive failure front continuously propagating and re-forking through an already-formed film — it never finishes. Pick craze-rule instead when the divider's mechanic is a one-shot fracture that draws in once via scroll and then holds; pick growth-ring/cambium-lay/lamina-dome instead when the mechanic is layered material being deposited outward over time, not an existing film failing.
Install
npx shadcn add https://design.helpmarq.com /r/divider-telephone-cord-delam.jsonSource
registry/core/divider-telephone-cord-delam/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// DividerTelephoneCordDelam — a full-width section divider rendered as a
// thin-film DELAMINATION BUCKLE: a compressed film bonded to a substrate
// that has debonded and popped upward into a wandering ridge. Real thin-film
// mechanics (silicon-nitride-on-polymer, DLC coatings, dried paint) show the
// debonding front does not run straight — it telescopes side to side as it
// advances, producing the "telephone cord" morphology: a roughly
// constant-width ridge, a characteristic wander wavelength, and recurring
// branch points where the front forks.
//
// DISTINCT FROM THE BANDED-ACCRETION FAMILY (growth-ring, cambium-lay,
// lamina-dome): those deposit material in concentric layers over time — the
// pattern GROWS OUTWARD as new rings are laid down. This divider has no
// deposition step at all; the film is already there, fully formed, and what
// moves is a compressive FAILURE FRONT propagating and re-forking through
// existing material. Same "material process over time" register, opposite
// physics — accretion adds, buckling fails.
//
// DISTINCT FROM CRAZE-RULE: craze-rule's fracture is a one-shot arrival —
// an IntersectionObserver fires once, a dash-offset transition draws the
// crack in, and after a brief idle creep it is finished and static. This
// divider never finishes: the buckle front is a continuous function of
// time, always sweeping and re-forking, because a delaminating film keeps
// failing under sustained compressive stress rather than reaching a healed
// resting state.
//
// GEOMETRY. The ridge centerline is a domain-scrolling sum of two sine
// waves — wavelength set as a FRACTION of the measured container width
// (not a fixed pixel constant), so the wander reads at the same relative
// scale whether the strip is narrow or wide. A second, independent process
// spawns short-lived parallel BRANCH ridges on a fixed cadence (derived
// deterministically from the clock, not stored state) that diverge from the
// main ridge, run alongside it for a stretch, and fade — the recurring
// "re-branching" the brief calls for, not a single fork.
//
// SHADING — RAISED IN BOTH THEMES WITHOUT INVERTING. A raised ridge needs a
// highlight edge and a shadow edge that stay bright/dark respectively
// regardless of theme; naively offsetting one edge toward the raw
// --foreground token and the other toward raw --background would FLIP which
// edge is bright between themes (foreground is dark-on-light in light
// theme, light-on-dark in dark theme) — that inversion is a real bug this
// project shipped once already. Instead both tokens are parsed to RGB and
// compared by luminance at read time; the emboss always uses whichever of
// the two is brighter for the highlight offset-stroke and whichever is
// darker for the shadow offset-stroke, so the same geometric edge of the
// ridge reads as lit in both polarities. The flat, un-buckled film between
// ridges is a faint --ns-muted baseline — never --border, which is a
// separator token invisible as a fill/stroke in light theme.
// ---------------------------------------------------------------------------
export interface DividerTelephoneCordDelamProps {
/** band height in px. Ridge width and wander wavelength derive from this and the measured width. Default 56. */
height?: number;
className?: string;
}
// wander wavelength as a fraction of measured width — keeps the ridge
// reading at the same relative scale narrow or wide, never "zoomed in"
const WIGGLES_PER_WIDTH = 5.2;
const HARMONIC_MULT = 2.35; // incommensurate multiplier -> telescoping wander, not a clean sinusoid
const SCROLL_SPEED_1 = 0.16; // rad/s
const SCROLL_SPEED_2 = 0.27;
const BRANCH_INTERVAL_MS = 2400; // cadence a new fork attempt spawns on
const BRANCH_DURATION_MS = 1900; // how long a fork stays visible before fading
const BRANCH_SPAN_FRAC = 0.16; // fraction of width a branch runs alongside the main ridge
// the frame prefers-reduced-motion freezes on: t=3.2s lands mid-life inside
// branch index 1 (spawns at 2.4s, 1.9s duration -> life progress ~0.42) with
// the main wander already well developed across the strip — a non-t0 frame
// with both the wandering ridge and a branch clearly formed, never a
// degenerate edge-of-cycle state
const STATIC_TIME_S = 3.2;
function hexToRgb(hex: string): [number, number, number] | null {
const s = hex.trim().replace("#", "");
if (s.length !== 6 && s.length !== 3) return null;
const full = s.length === 3 ? s.split("").map((c) => c + c).join("") : s;
const n = Number.parseInt(full, 16);
if (Number.isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const luminance = ([r, g, b]: [number, number, number]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
const rgbStr = ([r, g, b]: [number, number, number], a: number) => `rgba(${r},${g},${b},${a})`;
const lerp3 = (a: [number, number, number], b: [number, number, number], t: number): [number, number, number] => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
];
/** mulberry32 — deterministic given a seed, used only to key each branch's shape off its own index */
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;
};
}
function mainCenterY(x: number, t: number, width: number, midY: number, ampA: number, ampB: number) {
const k1 = (2 * Math.PI * WIGGLES_PER_WIDTH) / width;
const y1 = ampA * Math.sin(k1 * x - SCROLL_SPEED_1 * t);
const y2 = ampB * Math.sin(k1 * HARMONIC_MULT * x - SCROLL_SPEED_2 * t + 1.7);
return midY + y1 + y2;
}
function activeBranch(t: number, width: number) {
const idx = Math.floor((t * 1000) / BRANCH_INTERVAL_MS);
const localMs = t * 1000 - idx * BRANCH_INTERVAL_MS;
if (localMs > BRANCH_DURATION_MS) return null;
const rng = mulberry32(idx + 1);
const startFrac = 0.08 + rng() * 0.68;
const startX = startFrac * width;
const spanX = width * BRANCH_SPAN_FRAC * (0.8 + rng() * 0.4);
const side = rng() < 0.5 ? -1 : 1;
const life = localMs / BRANCH_DURATION_MS; // 0..1
return { startX, spanX, side, life };
}
export function DividerTelephoneCordDelam({
height = 56,
className = "",
}: DividerTelephoneCordDelamProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// -- tokens: start empty, assigned unconditionally from getComputedStyle,
// nothing paints before the first read below --
let mutedRgb: [number, number, number] = [0, 0, 0];
let brightRgb: [number, number, number] = [0, 0, 0];
let darkRgb: [number, number, number] = [0, 0, 0];
let tokensReady = false;
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
const fg = hexToRgb(cs.getPropertyValue("--foreground")) ?? [0, 0, 0];
const bg = hexToRgb(cs.getPropertyValue("--background")) ?? [0, 0, 0];
const mu = hexToRgb(cs.getPropertyValue("--ns-muted")) ?? [0, 0, 0];
mutedRgb = mu;
// luminance-sorted, not positionally fixed -> the same geometric edge
// of the ridge stays bright/dark in both themes (see header comment)
if (luminance(fg) >= luminance(bg)) {
brightRgb = fg;
darkRgb = bg;
} else {
brightRgb = bg;
darkRgb = fg;
}
tokensReady = true;
};
let width = 0;
let midY = height / 2;
let sized = false;
const resize = () => {
const rect = wrap.getBoundingClientRect();
width = rect.width;
if (width < 2) {
sized = false;
return;
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
midY = height / 2;
sized = true;
};
const ridgeW = () => Math.max(6, Math.min(22, height * 0.28));
const draw = (t: number) => {
if (!sized || !tokensReady) return;
ctx.clearRect(0, 0, width, height);
const ampA = height * 0.22;
const ampB = height * 0.1;
const rw = ridgeW();
// -- flat unbuckled film baseline: faint, always present, --ns-muted --
ctx.strokeStyle = rgbStr(mutedRgb, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, midY);
ctx.lineTo(width, midY);
ctx.stroke();
const drawBevelPath = (
pathFn: (x: number) => number,
x0: number,
x1: number,
w: number,
alphaMul: number,
) => {
const step = Math.max(2, width / 220);
// shadow stroke: offset toward the falling edge, broad + dim
ctx.strokeStyle = rgbStr(darkRgb, 0.5 * alphaMul);
ctx.lineWidth = w * 1.05;
ctx.lineCap = "round";
ctx.beginPath();
for (let x = x0; x <= x1; x += step) {
const y = pathFn(x) + w * 0.4;
if (x === x0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// highlight stroke: offset toward the rising edge, narrower + bright
ctx.strokeStyle = rgbStr(brightRgb, 0.55 * alphaMul);
ctx.lineWidth = w * 0.85;
ctx.beginPath();
for (let x = x0; x <= x1; x += step) {
const y = pathFn(x) - w * 0.4;
if (x === x0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// crest: thin, crisp, brightest — the true top of the buckle
ctx.strokeStyle = rgbStr(brightRgb, 0.85 * alphaMul);
ctx.lineWidth = w * 0.32;
ctx.beginPath();
for (let x = x0; x <= x1; x += step) {
const y = pathFn(x);
if (x === x0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
};
drawBevelPath((x) => mainCenterY(x, t, width, midY, ampA, ampB), 0, width, rw, 1);
const branch = activeBranch(t, width);
if (branch) {
// fade in/out across the branch's life so it materializes and
// dissolves rather than popping — the front "catching" then
// healing, matching real delamination re-forking
const fade = Math.sin(Math.min(1, branch.life) * Math.PI);
const x0 = branch.startX;
const x1 = Math.min(width, branch.startX + branch.spanX);
drawBevelPath(
(x) => {
const p = (x - x0) / (branch.spanX || 1);
const lateral = branch.side * rw * 1.8 * Math.sin(Math.min(1, p) * Math.PI);
return mainCenterY(x, t, width, midY, ampA, ampB) + lateral;
},
x0,
x1,
rw * 0.72,
fade,
);
}
};
// -- loop, paused offscreen and when the tab is hidden --
let raf = 0;
let visible = true;
const startTime = performance.now();
const loop = (now: number) => {
const t = (now - startTime) / 1000;
draw(t);
if (visible && !document.hidden) raf = requestAnimationFrame(loop);
};
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) visible = e.isIntersecting;
if (visible && !document.hidden && !reduced) {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(loop);
}
},
{ threshold: 0 },
);
io.observe(wrap);
const onVis = () => {
cancelAnimationFrame(raf);
if (!document.hidden && visible && !reduced) raf = requestAnimationFrame(loop);
};
document.addEventListener("visibilitychange", onVis);
const ro = new ResizeObserver(() => {
resize();
if (reduced) draw(STATIC_TIME_S);
});
ro.observe(wrap);
const mo = new MutationObserver(() => {
readTokens();
if (reduced) draw(STATIC_TIME_S);
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
readTokens();
resize();
if (reduced) {
draw(STATIC_TIME_S);
} else {
raf = requestAnimationFrame(loop);
}
return () => {
cancelAnimationFrame(raf);
io.disconnect();
ro.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [height]);
return (
<div
ref={wrapRef}
role="separator"
aria-orientation="horizontal"
className={`ns-dtcd w-full ${className}`}
style={{ height }}
>
<canvas ref={canvasRef} aria-hidden="true" className="block w-full" style={{ height }} />
</div>
);
}
Build spec
Build <DividerTelephoneCordDelam height? className?> as a drop-in replacement for <hr>/border-top between page sections, rendered on a single full-width <canvas> inside a <div role="separator" aria-orientation="horizontal">. SOURCE: real thin-film compressive buckling — a film under stress, bonded to a substrate, debonds and pops upward; the debonding front does not run straight, it telescopes side to side as it advances, producing the well-documented 'telephone cord' delamination morphology (seen in DLC coatings, silicon nitride films, dried paint): a ridge of roughly constant width, a characteristic wander wavelength, and recurring branch points where the front forks. GEOMETRY: the ridge centerline is two domain-scrolling sine terms — wavelength set as a FRACTION of the measured container width (~5.2 wiggles across the full strip), not a fixed pixel constant, so the wander reads at the same relative scale whether the divider is narrow or wide; a second sine at an incommensurate ~2.35x frequency multiplier is summed in to keep the wander from reading as a clean single sinusoid. Ridge (perpendicular) width derives from the band height, clamped 6-22px, so thickness stays proportional to the strip's own short dimension. PROPAGATION, FOREVER: the centerline is a continuous function of elapsed time (phase always advancing via requestAnimationFrame) — there is no settled or finished state, the film keeps failing indefinitely, matching the source process rather than a one-shot reveal. RE-BRANCHING: on a fixed ~2.4s cadence, a short parallel branch ridge is computed deterministically from the clock (mulberry32 seeded by cycle index, not accumulated state) diverging from the main ridge for roughly a sixth of the strip's width, fading in and back out across its ~1.9s life via a sine envelope — the front catching and re-healing, recurring for as long as the component is mounted, never a single fork. SHADING (raised in both themes, without inverting): each ridge segment is drawn as three offset strokes along its centerline — a broad dim stroke offset toward the falling edge (shadow), a narrower brighter stroke offset toward the rising edge (highlight), and a thin crisp crest stroke at the true centerline — the same offset-stroke emboss technique used for CSS bevels. --foreground and --background are both parsed to RGB and compared by LUMINANCE at read time; the highlight stroke always uses whichever token is brighter and the shadow stroke whichever is darker, so the same geometric edge of the ridge stays lit and the same edge stays shadowed in both light and dark theme — assigning the strokes positionally to the raw tokens instead would flip which edge reads as highlighted between themes, a real bug this project shipped once already. The flat, un-buckled film between ridge features is a faint 1px --ns-muted baseline, always present — never --border, which measures near-invisible as a fill/stroke against --background in light theme. --ns-accent never appears; nothing here is interactive. TOKENS AND PAINT ORDER: color fields start unassigned and are read unconditionally from getComputedStyle(document.documentElement) before the first resize/draw, re-read on a MutationObserver watching documentElement's class — draw() is gated on both a 'sized' and a 'tokensReady' flag so nothing paints before that first read completes. HOST: DPR-capped backing store (min(devicePixelRatio,2)), ResizeObserver on the wrapper (not window.resize) to catch layout-driven width changes, one requestAnimationFrame loop paused via IntersectionObserver (threshold 0) when scrolled offscreen and via visibilitychange when the tab is hidden. REDUCED MOTION: prefers-reduced-motion freezes the clock at t=3.2s and never starts the rAF loop — a deliberately chosen non-t0 frame where the main wander already spans the strip and a branch fork (cycle index 1, spawns at 2.4s, life progress ~0.42 at 3.2s) is clearly mid-development, not a degenerate edge-of-cycle state. DIFFERS FROM growth-ring/cambium-lay/lamina-dome: those are layered-growth DEPOSITION, material added in rings outward over time; this is compressive-buckling PROPAGATION, a failure front advancing and re-forking through a film that is already fully formed — opposite physics in the same 'material process over time' register. DIFFERS FROM craze-rule: craze-rule's fracture is a one-shot IntersectionObserver-armed reveal that draws in once and settles into a brief idle creep; this divider's front never finishes propagating and re-branching for as long as it is mounted, with no arrival trigger and no rest state to reach. A11Y: role=separator carries the divider's semantics and needs no accessible name; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate, and the registry's tab-reachability check is correctly skipped for a display-only component like this one. Props: height (band height in px, default 56) and className. Zero dependencies, canvas 2D only.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| height? | number | 56 | band height in px. Ridge width and wander wavelength derive from this and the measured width. Default 56. |
| className? | string | — | — |