Skip to main content

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.

Usage

registry/core/neon-tube-striation/demo.tsx
"use client";

import { NeonTubeStriation } from "./component";

export default function NeonTubeStriationDemo() {
  return (
    <div className="flex min-h-screen w-full flex-col items-center justify-center gap-10 bg-background px-8 py-10">
      <div className="w-full max-w-3xl">
        <h2 className="mb-3 text-sm text-foreground">Section one</h2>
        <p className="mb-8 max-w-prose text-xs text-ns-muted">
          A lit neon-tube divider in place of a plain rule — moving striations
          drift along the tube while the electrode ends slowly darken.
        </p>
        <NeonTubeStriation />
      </div>
      <div className="w-full max-w-3xl">
        <h2 className="mt-8 text-sm text-foreground">Section two</h2>
      </div>
    </div>
  );
}

Component

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

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

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.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 <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

PropTypeDefaultDescription
height?number96divider height in px — the container's smaller dimension geometry derives from. Default 96.
className?string—extra classes merged onto the rendered root element