Skip to main content

ns-ui

Expansion Gap Breather

A full-height divider between two independently-sized layout regions, sourced from continuous-welded-rail breather switches: the seam's tapered-blade teeth interlock and slide apart as a rendered rail temperature swings the gap between 4px and 22px on a 14s cycle, actually resizing the element that sits between its neighbours rather than drawing over a fixed rule.

Use when a vertical divider between two flex/grid regions of mismatched, independently-sized content that should read as visibly absorbing that mismatch rather than sitting as a static hairline — mechanism is a CWR breather switch's tapered-blade gap tracking a rendered temperature sine, so the divider's own width breathes and pushes its neighbours; pick a plain `border-l border-border` rule instead when the seam should be an inert, near-invisible structural hairline with no motion and no width change, since --border is explicitly too low-contrast in light theme to carry this component's teeth.

Install

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

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// ExpansionGapBreather — a divider between two independently-sized layout
// regions, sourced from continuous-welded-rail (CWR) breather switches: the
// tapered-blade gap that lets a rail run grow and shrink with temperature
// without buckling in heat or pulling apart in cold. The rendered "rail
// temperature" is a 14s sine (decoupled from any real diurnal cycle, per the
// round-9 rule) that drives the gap's opening between 4px (hot, nearly
// closed) and 22px (cold, open) — the element the browser lays out around
// its siblings actually resizes with that value, so the divider visibly
// absorbs the width mismatch it sits inside, not just draws it.
//
// The teeth are two interlocking stroked combs, not a rectangular slot: a
// left comb whose tongues reach rightward into the gap and a right comb
// whose tongues reach leftward, each tongue living in every OTHER pitch
// slot down the height so the two combs' tongues occupy disjoint y-ranges —
// a tip can cross past the opposite baseline (that is the interlock) with
// no collision, exactly a breather switch's tapered blades sliding past
// each other. Both combs are drawn inside a small fixed SVG overlay
// centred on the layout-affecting spacer div, so the teeth can extend past
// the spacer's own width without the spacer itself claiming that space.
//
// Tooth pitch derives from the container's own height (the one spatial
// dimension a full-height divider has to work with), clamped so 5-18 teeth
// always read as distinct fingers rather than a blur or a single fat wedge.
// ---------------------------------------------------------------------------

const MIN_GAP = 4;
const MAX_GAP = 22;
const MID_GAP = (MIN_GAP + MAX_GAP) / 2; // 13 — reduced-motion freeze value
const AMP_GAP = (MAX_GAP - MIN_GAP) / 2; // 9
const PERIOD_MS = 14000;

const TOOTH_DEPTH = 18; // px each comb's points reach into the gap
const SVG_WIDTH = MAX_GAP + TOOTH_DEPTH * 2 + 8; // 66 — fixed overlay width
const CENTER_X = SVG_WIDTH / 2;

const MIN_PITCH = 14;
const MAX_PITCH = 24;
const MIN_TEETH = 5;
const MAX_TEETH = 18;

/** Comb outline (stroked, never filled): a straight baseline rail at `baseX`
 * running the full height, with a tapered tongue reaching `direction *
 * TOOTH_DEPTH` past that baseline in every OTHER pitch slot — `slotParity`
 * picks which half of the slots belong to this side. Because the left and
 * right combs are given opposite parities, their tongues occupy disjoint
 * y-ranges: a tongue tip can cross past the opposite comb's baseline (that
 * IS the interlock) without ever colliding with the opposite comb's own
 * geometry, since nothing of the other side exists at that y. */
function buildCombPath(
  baseX: number,
  direction: 1 | -1,
  height: number,
  slotParity: 0 | 1
): string {
  const pitch = Math.max(MIN_PITCH, Math.min(MAX_PITCH, height / 8));
  const teeth = Math.max(MIN_TEETH, Math.min(MAX_TEETH, Math.round(height / pitch)));
  const step = height / teeth;
  const pts: string[] = [`${baseX.toFixed(1)},0`];
  for (let i = 0; i < teeth; i++) {
    const y0 = i * step;
    const y1 = (i + 1) * step;
    if (i % 2 === slotParity) {
      const tipX = (baseX + direction * TOOTH_DEPTH).toFixed(1);
      pts.push(`${tipX},${(y0 + step / 2).toFixed(1)}`);
    }
    pts.push(`${baseX.toFixed(1)},${y1.toFixed(1)}`);
  }
  return `M${pts.join(" L")}`;
}

export interface ExpansionGapBreatherProps {
  /** extra classes merged onto the spacer root */
  className?: string;
}

export function ExpansionGapBreather({ className = "" }: ExpansionGapBreatherProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const leftRef = useRef<SVGPathElement>(null);
  const rightRef = useRef<SVGPathElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const svg = svgRef.current;
    const left = leftRef.current;
    const right = rightRef.current;
    if (!root || !svg || !left || !right) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let disposed = false;
    let visible = true;
    let raf = 0;
    let height = 0;
    const originMs = performance.now(); // t0 == gap 4 (hot/closed), always

    const paint = (gap: number) => {
      // layout write rounded to whole px — the flex row this sits in would
      // otherwise reflow at 60fps and make sibling text shimmer on subpixel
      // width changes; the tongues themselves still slide on the float.
      root.style.width = `${Math.round(gap)}px`;
      const leftBase = CENTER_X - gap / 2;
      const rightBase = CENTER_X + gap / 2;
      left.setAttribute("d", buildCombPath(leftBase, 1, height, 0));
      right.setAttribute("d", buildCombPath(rightBase, -1, height, 1));
    };

    const loop = () => {
      raf = 0;
      if (disposed || !visible) return;
      // a pure function of elapsed real time since mount — pausing/resuming
      // (tab hidden, scrolled offscreen) never desyncs the phase, it just
      // stops and resumes drawing the same continuous curve.
      const t = ((performance.now() - originMs) % PERIOD_MS) / PERIOD_MS;
      const gap = MID_GAP - AMP_GAP * Math.cos(2 * Math.PI * t);
      paint(gap);
      raf = requestAnimationFrame(loop);
    };

    const start = () => {
      if (reduced) {
        paint(MID_GAP); // mid-cycle: average width, teeth half-interlocked
        return;
      }
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const ro = new ResizeObserver((entries) => {
      const h = entries[0]?.contentRect.height ?? root.clientHeight;
      if (h > 0 && Math.abs(h - height) > 0.5) {
        height = h;
        if (reduced) paint(MID_GAP);
      }
    });
    ro.observe(root);
    height = root.clientHeight || 120;

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !reduced && !raf) raf = requestAnimationFrame(loop);
    });
    io.observe(root);

    start();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      ro.disconnect();
      io.disconnect();
    };
  }, []);

  return (
    <div
      ref={rootRef}
      role="separator"
      aria-orientation="vertical"
      className={`relative h-full shrink-0 ${className}`}
      style={{ width: MID_GAP }}
    >
      <svg
        ref={svgRef}
        aria-hidden="true"
        focusable="false"
        width={SVG_WIDTH}
        height="100%"
        className="pointer-events-none absolute top-0 h-full"
        style={{ left: "50%", transform: `translateX(-${CENTER_X}px)`, overflow: "hidden" }}
      >
        <path
          ref={leftRef}
          d=""
          fill="none"
          stroke="var(--foreground)"
          strokeWidth={1.6}
          strokeLinejoin="round"
          strokeLinecap="round"
        />
        <path
          ref={rightRef}
          d=""
          fill="none"
          stroke="var(--foreground)"
          strokeWidth={1.6}
          strokeLinejoin="round"
          strokeLinecap="round"
        />
      </svg>
    </div>
  );
}
Build spec

A full-height divider, `role="separator" aria-orientation="vertical"`, whose own inline width is the animated value — not a fixed-width element with motion painted inside it, so it genuinely pushes its flex/grid siblings as it moves. Width oscillates on a pure function of elapsed real time since mount: `originMs = performance.now()` is captured once in the effect, then every frame `t = ((performance.now() - originMs) % 14000) / 14000`, `gap = 13 - 9 * cos(2*PI*t)`, giving a 14-second full breathe cycle between 4px (hot, rail expanded, gap nearly closed) and 22px (cold, rail contracted, gap open); the cosine phase means t0 is always the 4px trough and the divider opens monotonically for the first 7s of every cycle, so t0/2.5s/5s samples are always distinct and moving in one direction, matching the round-9 legibility requirement. Because the loop reads a modulo of elapsed real time rather than an accumulated per-frame delta, pausing when an IntersectionObserver reports the divider off-screen and resuming later never desyncs the phase — it simply stops and resumes painting the same continuous curve. The teeth are two interlocking STROKED combs, not a rectangular slot and not a filled mass: each comb is a straight baseline rail at its own x plus a tapered tongue reaching TOOTH_DEPTH (18px) past that baseline in every OTHER pitch slot down the height (`i % 2 === slotParity`, left comb parity 0, right comb parity 1) — so the two combs' tongues occupy disjoint y-ranges by construction and a tongue tip can cross past the opposite comb's baseline (that crossing IS the interlock) with zero collision, since nothing of the other comb exists at that y. Both paths are drawn `fill="none" stroke="var(--foreground)"` at 1.6px (a CSS custom property resolved natively by the SVG, no getComputedStyle read needed since nothing here is canvas) inside a small fixed-width (66px) SVG overlay, centred (`left: 50%`, `translateX(-33px)`) over the spacer div so the tongues can extend past the spacer's own animated width without the spacer claiming that space in layout — the spacer is what pushes siblings, the overlay is purely visual. Tooth pitch is `clamp(height / 8, 14, 24)` and tooth count is `clamp(round(height / pitch), 5, 18)`, both derived from the container's own height via a ResizeObserver that only re-triggers on an actual >0.5px height change (guarding against the animation loop's own per-frame width writes) so 5-18 distinct interlocking fingers always read at card scale regardless of how tall the divider's container is. `--foreground` is used deliberately instead of `--border` because `--border` sits at ~1.1:1 contrast in light theme and this is meant to read as a structural divider, not a hairline; strokes keep the total visual weight thin (separator weight) rather than the solid slab a filled, edge-anchored comb would produce. `autoplay: none` — the breathe is driven entirely by elapsed real time, there is nothing for a pointer/press/scroll input to do, and per the round-8b bug a press-latched irreversible state would need `none` anyway even if there were interaction. Under `prefers-reduced-motion` the rAF loop never starts; the divider paints once at `gap = 13` (MID_GAP), the average width with tongue tips crossing 5px past the opposite baseline — the most-structured single frame, showing both the open channel and the interlocking geometry rather than either fully-closed or fully-open extreme. Cleanup on unmount cancels the rAF and disconnects both the ResizeObserver and IntersectionObserver. Zero dependencies, DOM+SVG+CSS only, no canvas, colours limited to `--foreground`.

Props

PropTypeDefaultDescription
className?stringextra classes merged onto the spacer root