ns-ui
Neon Tube Striation
A horizontal section divider rendered as the positive column of a lit glow-discharge tube: a traveling ionization-wave striation pattern drifts along its length while faint sputtering darkening deepens at both electrode ends over time.
Use when Pick neon-tube-striation when the surface is a section divider that should read as a genuinely lit object with a slow, continuous internal process — a drifting ionization-wave band pattern plus a capped electrode-darkening ramp, both derived from real gas-discharge tube physics. Pick divider-petscii-vu instead when the divider should read as a discrete two-state instrument responding to an amplitude signal, and craze-rule instead when the mechanic should be a one-shot propagating fracture with no ongoing internal process at rest.
Install
npx shadcn add https://design.helpmarq.com /r/neon-tube-striation.jsonSource
registry/core/neon-tube-striation/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// NeonTubeStriation — a horizontal section divider rendered as the positive
// column of a lit glow-discharge (neon) tube.
//
// Real mechanic: in the positive column of a glow-discharge tube — the long
// uniform-looking stretch away from the electrodes — the plasma commonly
// self-organizes into moving striations, an ionization-wave instability that
// produces alternating brighter/dimmer bands drifting along the tube's axis
// at a roughly constant rate for a given gas fill. Separately, electrode
// sputtering slowly darkens the glass near both ends as cathode material
// erodes and deposits on the inside of the tube — the familiar blackened
// ends of an aged neon sign or fluorescent tube.
//
// Rendering is two passes over one capsule path: a blurred halo underlay for
// glow, then a crisp core clipped to the same pill shape and filled column
// by column, each column's luminance the product of a base "lit tube" level,
// the traveling striation modulation, an aperiodic low-frequency current
// flicker, and the end-zone sputtering ramp.
//
// The striation phase is computed directly from elapsed wall-clock time
// (phase = 2π · elapsedMs / 4000), never accumulated per rendered frame —
// that is what keeps the drift rate identical regardless of display refresh
// rate, the aliasing failure a real per-frame increment would risk at high
// Hz. The 4-7Hz current noise is resampled on its own aperiodic timer and
// eased toward, not read straight off the 60Hz paint loop either.
// ---------------------------------------------------------------------------
const WAVELENGTH_FRAC = 0.07; // striation wavelength, fraction of tube length
const DRIFT_PERIOD_MS = 4000; // one full wavelength of phase advance
const STRIATION_RATIO = 1.6; // bright:dark luminance ratio around base
const STRIATION_AMP = (STRIATION_RATIO - 1) / (STRIATION_RATIO + 1); // unclamped multiplicative amplitude
const NOISE_AMP = 0.03; // ±3% whole-tube current flicker
const NOISE_MIN_HZ = 4;
const NOISE_MAX_HZ = 7;
const NOISE_EASE = 0.15; // per-frame ease toward the current resampled target
const END_ZONE_FRAC = 0.06; // each electrode darkening zone, fraction of tube length
const END_DARK_MAX = 0.35; // cap on sputtering darkening
const END_DARK_TAU_S = 30; // 1 - exp(-t/30s) ramp
const BASE_MIX = 0.82; // baseline "lit tube" position between --background (0) and --foreground (1)
const MIN_MIX = 0.42; // floor so the dimmest striation trough stays visibly lit in light theme
const MAX_MIX = 0.99;
const STROKE_FRAC = 0.045; // tube diameter as a fraction of the container's height
const STROKE_MIN = 3;
const STROKE_MAX = 10;
const REDUCED_FREEZE_DEG = 130; // reduced-motion freeze phase
const REDUCED_FREEZE_S = 20; // seconds into the sputter ramp shown at freeze
const COL_STEP = 2; // px per rendered column — striations don't need per-pixel resolution
let swatchCtx: CanvasRenderingContext2D | null = null;
function resolveTokenRGB(token: string): [number, number, number] | null {
if (!swatchCtx) {
const c = document.createElement("canvas");
c.width = 1;
c.height = 1;
swatchCtx = c.getContext("2d", { willReadFrequently: true });
}
const ctx = swatchCtx;
if (!ctx) return null;
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = token;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
if (r === undefined || g === undefined || b === undefined) return null;
return [r, g, b];
}
function mixRGB(bg: [number, number, number], fg: [number, number, number], t: number): string {
const r = Math.round(bg[0] + (fg[0] - bg[0]) * t);
const g = Math.round(bg[1] + (fg[1] - bg[1]) * t);
const b = Math.round(bg[2] + (fg[2] - bg[2]) * t);
return `rgb(${r}, ${g}, ${b})`;
}
function pillPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {
const r = h / 2;
ctx.beginPath();
if (typeof ctx.roundRect === "function") {
ctx.roundRect(x, y, w, h, r);
} else {
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2);
ctx.lineTo(x + r, y + h);
ctx.arc(x + r, y + r, r, Math.PI / 2, (3 * Math.PI) / 2);
}
ctx.closePath();
}
/** column luminance mix-fraction (0..1, background..foreground) at local
* x (0..tubeLength) and elapsed real time t (ms), given the current sputter
* ramp seconds and aperiodic noise offset already resolved for this frame */
function columnMix(x: number, tubeLength: number, phase: number, noise: number, rampT: number): number {
const wavelength = Math.max(1, WAVELENGTH_FRAC * tubeLength);
const striation = 1 + STRIATION_AMP * Math.sin((2 * Math.PI * x) / wavelength - phase);
const zone = END_ZONE_FRAC * tubeLength;
const ramp = 1 - Math.exp(-rampT / END_DARK_TAU_S);
const leftFrac = zone > 0 ? Math.max(0, 1 - x / zone) : 0;
const rightFrac = zone > 0 ? Math.max(0, 1 - (tubeLength - x) / zone) : 0;
const sputter = END_DARK_MAX * ramp * Math.max(leftFrac, rightFrac);
const raw = BASE_MIX * striation * (1 + noise) * (1 - sputter);
return Math.min(MAX_MIX, Math.max(MIN_MIX, raw));
}
export interface NeonTubeStriationProps {
/** divider height in px — the container's smaller dimension geometry derives from. Default 96. */
height?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function NeonTubeStriation({ height = 96, className = "" }: NeonTubeStriationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let disposed = false;
let sized = false;
let tokensRead = false;
let width = 0;
let strokeW = STROKE_MIN;
let bgRGB: [number, number, number] | null = null;
let fgRGB: [number, number, number] | null = null;
// no fallback colour literals — if the tokens aren't resolvable yet,
// tokensRead stays false and draw() simply doesn't paint this pass
const readTokens = () => {
const styles = getComputedStyle(document.documentElement);
const bgTok = styles.getPropertyValue("--background").trim();
const fgTok = styles.getPropertyValue("--foreground").trim();
if (!bgTok || !fgTok) return;
const bg = resolveTokenRGB(bgTok);
const fg = resolveTokenRGB(fgTok);
if (!bg || !fg) return;
bgRGB = bg;
fgRGB = fg;
tokensRead = true;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) {
sized = false;
return;
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
width = rect.width;
strokeW = Math.min(STROKE_MAX, Math.max(STROKE_MIN, rect.height * STROKE_FRAC));
sized = true;
if (!tokensRead) readTokens();
};
// -- aperiodic 4-7Hz current noise: resampled on its own timer, eased
// toward each frame — never a value read straight off the 60Hz loop ---
let noiseTarget = 0;
let noiseCurrent = 0;
let noiseTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleNoise = () => {
const hz = NOISE_MIN_HZ + Math.random() * (NOISE_MAX_HZ - NOISE_MIN_HZ);
const ms = 1000 / hz;
noiseTimer = setTimeout(() => {
noiseTarget = (Math.random() * 2 - 1) * NOISE_AMP;
scheduleNoise();
}, ms);
};
const draw = (phase: number, rampT: number, noise: number) => {
if (!sized || !tokensRead || !bgRGB || !fgRGB) return;
const bg = bgRGB;
const fg = fgRGB;
const cy = height / 2 - strokeW / 2;
ctx.clearRect(0, 0, width, height);
// pass 1: blurred halo underlay
ctx.save();
ctx.filter = `blur(${Math.max(2, strokeW * 1.6)}px)`;
ctx.globalAlpha = 0.4;
pillPath(ctx, 0, cy, width, strokeW);
ctx.fillStyle = mixRGB(bg, fg, BASE_MIX);
ctx.fill();
ctx.restore();
// pass 2: crisp core, clipped to the tube, column by column
ctx.save();
pillPath(ctx, 0, cy, width, strokeW);
ctx.clip();
for (let x = 0; x < width; x += COL_STEP) {
const mix = columnMix(x, width, phase, noise, rampT);
ctx.fillStyle = mixRGB(bg, fg, mix);
ctx.fillRect(x, cy, COL_STEP + 1, strokeW);
}
ctx.restore();
};
let raf = 0;
let start = 0;
const loop = (now: number) => {
if (!visible) {
raf = 0;
return;
}
if (!start) start = now;
const elapsedMs = now - start;
noiseCurrent += (noiseTarget - noiseCurrent) * NOISE_EASE;
// phase derived directly from elapsed real time, not per-frame steps —
// the decoupling that keeps drift rate independent of display Hz
const phase = (2 * Math.PI * elapsedMs) / DRIFT_PERIOD_MS;
const rampT = elapsedMs / 1000;
draw(phase, rampT, noiseCurrent);
raf = requestAnimationFrame(loop);
};
const mo = new MutationObserver(() => {
readTokens();
if (reduced) drawReducedFrame();
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resize();
if (reduced) drawReducedFrame();
}, 150);
};
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
let visible = true;
const io = new IntersectionObserver((entries) => {
const wasVisible = visible;
visible = entries[0]?.isIntersecting ?? true;
if (visible && !wasVisible && !reduced && sized && !raf) {
raf = requestAnimationFrame(loop);
}
});
io.observe(canvas);
const onVis = () => {
cancelAnimationFrame(raf);
raf = 0;
if (!document.hidden && visible && !reduced && sized) {
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
const drawReducedFrame = () => {
// freeze at striation phase 130° into the cycle — an asymmetric band
// arrangement, more legible than the near-symmetric phase-0 frame —
// with the sputter ramp partway in so both mechanics read at a glance
const freezePhase = (REDUCED_FREEZE_DEG / 360) * 2 * Math.PI;
draw(freezePhase, REDUCED_FREEZE_S, 0);
};
document.fonts.ready.then(() => {
if (disposed) return;
readTokens();
resize();
if (reduced) {
drawReducedFrame();
return;
}
scheduleNoise();
raf = requestAnimationFrame(loop);
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
raf = 0;
if (resizeTimer) clearTimeout(resizeTimer);
if (noiseTimer) clearTimeout(noiseTimer);
mo.disconnect();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [height]);
return (
<div role="separator" aria-orientation="horizontal" className={`ns-nts w-full ${className}`}>
<canvas ref={canvasRef} aria-hidden="true" className="block w-full" style={{ height }} />
</div>
);
}
Build spec
Build <NeonTubeStriation height? className?> as a full-width divider: a <div role="separator" aria-orientation="horizontal"> wrapping a single <canvas w-full h-full/style-height>, a drop-in replacement for <hr>/border-top between page sections. The tube is a horizontal rounded capsule (a pill path, radius = half its diameter) spanning the canvas's full width, vertically centred; diameter (strokeW) is derived from the container's smaller dimension — the divider's own height prop (default 96) — at STROKE_FRAC = 4.5% of that height, clamped to [3, 10]px, so a taller/shorter divider still reads as a thin lit line inside its box rather than a fat bar. Every rendered frame draws two passes over the same pill path: pass 1 is a blurred halo underlay (ctx.filter = blur(strokeW*1.6px), globalAlpha 0.4, filled with the base-luminance mix colour) for the glow bloom; pass 2 clips to the crisp pill and fills it in 2px-wide vertical column strips, each column's colour independently computed. TOKENS: colour is never a literal — --background and --foreground are read once via getComputedStyle(document.documentElement) and resolved to actual RGB by painting each token string onto an offscreen 1x1 canvas and reading back getImageData (robust to hex/rgb()/named-colour token values), refreshed on a MutationObserver watching documentElement's class attribute for theme flips; every rendered colour is a straight RGB lerp between that background and foreground pair (mixRGB(bg, fg, t)), never accent — --ns-accent never appears anywhere in this component, and --border is never used as a fill or stroke. LUMINANCE MODEL: each column's mix fraction t (0 = --background, 1 = --foreground) is BASE_MIX(0.82) × striation × (1+noise) × (1-sputter), clamped to [MIN_MIX 0.42, MAX_MIX 0.99] — the 0.42 floor is what keeps the darkest striation trough meaningfully above --background in light theme, the specific failure mode (a neon tube fading invisible at its dimmest phase) this component is built to avoid. STRIATION: a travelling sinusoid, wavelength = 7% of the tube's rendered width (10-14 visible bands across a typical divider), amplitude tuned so unclamped bright:dark ratio is 1.6:1 around the base. Its phase is computed directly from elapsed real time every frame — phase = 2π × elapsedMs / 4000 (DRIFT_PERIOD_MS), one full wavelength of phase advance every 4 seconds — never accumulated as a fixed per-rendered-frame step; that is the deliberate decoupling of the rendered drift rate from a raw per-frame increment, which is what keeps the visible drift speed identical regardless of display refresh rate instead of aliasing faster on a high-Hz monitor. GAS NOISE: a whole-tube ±3% luminance multiplier resampled on its own aperiodic timer at a random 4-7Hz interval (not tied 1:1 to the 60Hz paint loop) and eased 15%/frame toward that resampled target, so the flicker is smooth rather than a stepped blink. SPUTTERING: two fixed end zones, each the first/last 6% of the tube's width, darken via 1-exp(-t/30s) toward a capped -35% luminance multiplier as elapsed seconds grow, strongest exactly at the edge and tapering linearly to zero at the 6% boundary — capped, not unbounded, so the tube stays lit and alive rather than slowly going dark forever. RESTING LOOP: t0 has striation phase 0 and near-zero sputtering (freshly struck); by 2.5s the band phase has advanced ~225° (visibly different band positions) with slightly deeper end darkening; by 5s the phase has advanced roughly another 112° past that and end-darkening is measurably deeper than at t0 — an unbounded, unforced loop that never completes or resets. REDUCED MOTION: a single frame is painted once at mount with striation phase locked to 130° (an asymmetric, clearly banded arrangement — the phase-0 frame reads near-symmetric and less legible) and the sputter ramp evaluated at a fixed t=20s (partway into its 30s time constant, so both mechanics are visible in the one frozen frame) with zero gas noise; no rAF loop runs at all in this mode. LIFECYCLE: sizing happens via a ResizeObserver-driven, 150ms-debounced resize() reading canvas.getBoundingClientRect (capped devicePixelRatio<=2), first paint is gated behind document.fonts.ready so nothing paints before layout and tokens are both settled, an IntersectionObserver pauses/resumes the rAF loop off-screen, and a visibilitychange listener cancels and restarts the loop on tab hide/show so a queued frame from before hiding can never double up with a freshly scheduled one. Cleanup on unmount cancels the rAF, clears both the resize-debounce and noise-resample timers, and disconnects the MutationObserver, ResizeObserver and IntersectionObserver. A11Y: role=separator carries the divider's semantics with no accessible name required; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate. `autoplay: none` because the tube animates purely on its own internal clock with zero pointer/press/scroll input to drive. Props: height (divider height in px, default 96 — the geometry-deriving smaller dimension), className. Zero dependencies, canvas + CSS only, no WebGL.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| height? | number | 96 | divider height in px — the container's smaller dimension geometry derives from. Default 96. |
| className? | string | — | extra classes merged onto the rendered root element |