ns-ui
Divider Mosaic Split
A full-width section divider rendered in real NAPLPS 'separated mosaic' mode: the same 2-wide x 3-tall sextant sub-cell grid this registry's divider-teletext-mosaic already ships in contiguous mode, but every lit sub-cell block is drawn inset by a genuine luminance gap on all sides, so the band reads as a field of small floating tiles rather than a solid mosaic shape, painted by a left-to-right column-group write sweep.
Use when Pick divider-mosaic-split when the surface is a section divider that should read as a genuine NAPLPS/videotex page painting in with its 'separated mosaic' attribute mode — small floating tiles with a real gap between blocks, swept left to right in column-groups. Pick divider-teletext-mosaic instead when the divider should read as the contiguous mosaic mode of the same 2x3 sextant addressing (solid mosaic shapes, no gap, row-by-row top-to-bottom sweep) — the two share the sub-cell grid but render opposite attribute modes on opposite sweep axes, never mix them into one component.
Install
npx shadcn add https://design.helpmarq.com /r/divider-mosaic-split.jsonSource
registry/core/divider-mosaic-split/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// DividerMosaicSplit — a full-width section divider rendered in NAPLPS
// "separated mosaic" mode.
//
// NAPLPS (the videotex standard behind Prestel, Telidon, and early consumer
// online services' graphics mode) and the teletext alphamosaic sets it
// descends from both address a 2-wide x 3-tall sub-cell grid per character
// cell — the same sextant addressing this registry's divider-teletext-mosaic
// already ships in its CONTIGUOUS mode (lit sub-cells flush against their
// neighbours, forming solid shapes). NAPLPS adds a second selectable
// attribute per mosaic run: SEPARATED — the identical 6-bit sub-cell code,
// but every lit block is drawn inset by a fixed gap on all four sides, so
// the mosaic reads as a field of small floating tiles instead of a solid
// silhouette. Real terminals let a page author toggle this per attribute
// byte; broadcasters used separated mode for graphs/diagrams where
// individual sub-cell boundaries needed to stay legible, contiguous for
// photo-like fills. This is the specific variant divider-teletext-mosaic's
// own source comment names but does not build.
//
// THE GAP IS THE ENTIRE IDENTITY of this component and must be a real
// luminance gap, never a --border stroke (--border measures ~1.1:1 in light
// theme and would make the gap structurally invisible right where it needs
// to prove separated mode is different from contiguous). The gap is literal
// unpainted --background between two --foreground/--ns-muted fills, inset
// 18% of the sub-cell's side per edge, floored to a minimum physical pixel
// count so it never anti-aliases away at small card-scale cell sizes.
//
// GRID: this band is a single row of character cells (unlike the teletext
// sibling's 6-row page), so geometry derives entirely from the band's own
// height: sub-cell side = height / 3 (exactly 3 sub-cell rows fill the
// band), character cell = 2 sub-cells wide x 3 tall, i.e. cellW = 2 *
// subCell, cellH = height. Column count = floor(width / cellW).
//
// ALIVE AT REST: real videotex pages arrive byte-serial off a transmission
// line — this band reproduces that with a write cursor sweeping LEFT TO
// RIGHT across column-groups (not top-to-bottom across rows, the teletext
// sibling's mechanic), re-sampling a slow generative field into a fresh
// 6-bit pattern per column-group on a 90ms cadence, faster than the
// sibling's 420ms per-row cadence since this sweeps narrow column-groups
// across a single short band rather than full character rows across a
// six-row page. A completed left-to-right pass holds for an 800ms sync
// pause before the next pass begins.
// ---------------------------------------------------------------------------
const SUB_COLS = 2; // sub-cell grid width per character cell (NAPLPS sextant)
const SUB_ROWS = 3; // sub-cell grid height per character cell (NAPLPS sextant)
const GROUP_COLS = 3; // character cells per write step (a "column group")
const COLUMN_INTERVAL_MS = 90; // time between successive column-group writes
const PAUSE_MS = 800; // sync pause between a completed pass and the next
const GAP_RATIO = 0.18; // gap inset, fraction of a sub-cell's side, per edge
const MIN_GAP_PX = 1; // floor so the gap never anti-aliases away at small scale
// three non-commensurate traveling components summed and normalized — a
// distinct field from the teletext sibling's (different frequencies/phase
// structure), slow enough that a 90ms column-group cadence still samples
// visibly different content pass over pass.
function fieldValue(sx: number, sy: number, t: number) {
const f =
Math.sin(sx * 0.5 - t * 1.1) +
0.55 * Math.sin(sy * 0.8 + sx * 0.18 + t * 0.44) +
0.35 * Math.sin((sx - sy) * 0.3 + t * 0.71);
const v = (f + 1.9) / 3.8;
return Math.min(1, Math.max(0, v));
}
function subCellPattern(colGlobal: number, t: number) {
// returns a 6-bit mask, bit i = sub-cell i (0=top-left ... 5=bottom-right)
let bits = 0;
for (let subRow = 0; subRow < SUB_ROWS; subRow++) {
for (let subCol = 0; subCol < SUB_COLS; subCol++) {
const idx = subRow * SUB_COLS + subCol;
const sx = colGlobal * SUB_COLS + subCol;
const sy = subRow;
const v = fieldValue(sx, sy, t);
if (v > 0.5) bits |= 1 << idx;
}
}
return bits;
}
export interface DividerMosaicSplitProps {
/** band height in px; sub-cell side is height / 3. Default 42. */
height?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function DividerMosaicSplit({
height = 42,
className = "",
}: DividerMosaicSplitProps) {
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 bg = "";
let fg = "";
let muted = "";
const subCell = Math.max(2, height / SUB_ROWS);
const cellW = subCell * SUB_COLS;
const gap = Math.max(MIN_GAP_PX, Math.round(subCell * GAP_RATIO));
const block = Math.max(1, subCell - gap * 2);
let cols = 0;
let sized = false;
let lastWidth = 0;
// per-column state: 6-bit pattern and the timestamp (component-local
// clock, seconds) it was last written, for the write-flash
let patterns: Uint8Array = new Uint8Array(0);
let writeAt: Float32Array = new Float32Array(0);
const readTokens = () => {
const root = getComputedStyle(document.documentElement);
// fallbacks are CSS keywords, never literal colour values — only
// reached if the token is somehow undefined on documentElement
bg = root.getPropertyValue("--background").trim() || "transparent";
fg = root.getPropertyValue("--foreground").trim() || "currentColor";
muted = root.getPropertyValue("--ns-muted").trim() || fg;
};
const allocCols = () => {
patterns = new Uint8Array(cols);
writeAt = new Float32Array(cols).fill(-Infinity);
};
// globalT: seconds, drives the generative field. Never resets.
let globalT = 0;
const numGroups = () => Math.max(1, Math.ceil(cols / GROUP_COLS));
// step machine: stepIndex 0..numGroups-1 writes that column-group,
// stepIndex===numGroups is the sync pause.
let stepIndex = 0;
let stepAcc = 0;
const stepDuration = (i: number) => (i < numGroups() ? COLUMN_INTERVAL_MS : PAUSE_MS);
const writeGroup = (groupIndex: number) => {
if (!sized) return;
const start = groupIndex * GROUP_COLS;
const end = Math.min(cols, start + GROUP_COLS);
for (let c = start; c < end; c++) {
patterns[c] = subCellPattern(c, globalT);
writeAt[c] = globalT;
}
};
// full pass at increasing globalT — used after a resize (a new column
// count leaves nothing painted otherwise) and, twice, to reach the
// reduced-motion freeze frame.
const warmPass = () => {
const groups = numGroups();
for (let g = 0; g < groups; g++) {
globalT += COLUMN_INTERVAL_MS / 1000;
writeGroup(g);
}
stepIndex = 0;
stepAcc = 0;
};
// the FIRST paint only: a real videotex page never opens fully blank,
// but it also never opens fully painted — it opens mid-transmission.
// Write roughly the first half of the column-groups so t0 reads as a
// genuine mid-sweep frame (half written, half still open background),
// with the just-written group's flash visible at the leading edge, and
// park the step machine right there so the loop continues the same
// sweep rather than restarting it.
const partialWarmStart = () => {
const groups = numGroups();
const half = Math.max(1, Math.floor(groups / 2));
for (let g = 0; g < half; g++) {
globalT += COLUMN_INTERVAL_MS / 1000;
writeGroup(g);
}
stepIndex = half;
stepAcc = 0;
};
const resize = () => {
const { width } = canvas.getBoundingClientRect();
if (width < 2) {
sized = false;
return;
}
if (sized && Math.abs(width - lastWidth) < 1) return;
lastWidth = width;
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);
cols = Math.max(4, Math.floor(width / cellW));
sized = true;
allocCols();
};
const draw = () => {
if (!sized) return;
ctx.clearRect(0, 0, cols * cellW + cellW, height);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, cols * cellW + cellW, height);
for (let c = 0; c < cols; c++) {
const bits = patterns[c];
if (bits === 0) continue;
const flashing = !reduced && globalT - writeAt[c] < COLUMN_INTERVAL_MS / 1000;
ctx.fillStyle = flashing ? fg : muted;
const x = c * cellW;
for (let subRow = 0; subRow < SUB_ROWS; subRow++) {
for (let subCol = 0; subCol < SUB_COLS; subCol++) {
const idx = subRow * SUB_COLS + subCol;
if (!(bits & (1 << idx))) continue;
const bx = Math.round(x + subCol * subCell + gap);
const by = Math.round(subRow * subCell + gap);
ctx.fillRect(bx, by, Math.ceil(block), Math.ceil(block));
}
}
}
};
// -- loop ----------------------------------------------------------------
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dtMs = last ? Math.min(250, now - last) : 1000 / 60;
last = now;
globalT += dtMs / 1000;
stepAcc += dtMs;
const groups = numGroups();
let guard = 0;
while (stepAcc >= stepDuration(stepIndex) && guard < groups + 1) {
stepAcc -= stepDuration(stepIndex);
if (stepIndex < groups) writeGroup(stepIndex);
stepIndex = (stepIndex + 1) % (groups + 1);
guard++;
}
draw();
if (!document.hidden) raf = requestAnimationFrame(loop);
};
const mo = new MutationObserver(() => {
readTokens();
if (reduced) draw();
});
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;
readTokens();
resize();
// a resize invalidates the previous column count/layout entirely —
// repaint the whole band rather than leaving half of it blank
if (sized) {
warmPass();
if (reduced) stepIndex = numGroups();
}
draw();
}, 150);
};
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
const io = new IntersectionObserver(
(entries) => {
const visible = entries[0]?.isIntersecting;
if (visible && !reduced && sized) {
cancelAnimationFrame(raf);
last = 0;
raf = requestAnimationFrame(loop);
} else if (!visible) {
cancelAnimationFrame(raf);
}
},
{ threshold: 0 }
);
io.observe(canvas);
const onVis = () => {
cancelAnimationFrame(raf);
if (!document.hidden && !reduced && sized) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
// no paint before the first token read
readTokens();
resize();
if (reduced) {
// freeze mid-PAUSE_MS hold: a warm start pass, then one full extra
// pass past it so every column-group has been rewritten at least
// twice (field clear of its cold-start state), then land stepIndex
// on the pause slot with no flash — every column filled, full
// separated-gap tile structure legible edge to edge. Explicitly not
// t0's half-written sweep state.
warmPass();
warmPass();
stepIndex = numGroups();
draw();
} else {
partialWarmStart();
draw();
raf = requestAnimationFrame(loop);
}
return () => {
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
mo.disconnect();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [height]);
return (
<div
role="separator"
aria-orientation="horizontal"
className={`ns-dms w-full ${className}`}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="block w-full"
style={{ height }}
/>
</div>
);
}
Build spec
Build <DividerMosaicSplit height? className?> as a full-width <canvas> band wrapped in a <div role="separator" aria-orientation="horizontal">, a drop-in replacement for <hr>/border-top between page sections. SOURCE, NOT INVENTED: NAPLPS (North American Presentation Level Protocol Syntax, the videotex standard behind Prestel, Telidon, and early consumer online services' graphics mode) and the teletext alphamosaic character sets it descends from both address a 2-wide x 3-tall sub-cell grid per character cell — the same sextant addressing this registry's divider-teletext-mosaic ships in CONTIGUOUS mode (lit sub-cells flush against their neighbours). NAPLPS adds a second selectable attribute per mosaic run: SEPARATED — the identical 6-bit sub-cell code, but every lit block is drawn inset by a fixed gap on all four sides so the mosaic reads as small floating tiles instead of a solid silhouette. Real terminals let a page author toggle this per attribute byte; broadcasters used separated mode for graphs/diagrams needing legible sub-cell boundaries, contiguous for photo-like fills. THE DISCRIMINATOR from divider-teletext-mosaic: same 2x3 sextant sub-cell grid and same 6-bit addressing, but (a) every lit block is inset by a real gap instead of drawn edge-to-edge, and (b) the write sweep moves left-to-right across narrow column-groups on a 90ms cadence instead of top-to-bottom across full character rows on a 420ms cadence — a genuinely different real broadcast attribute mode and sweep axis, not a reskin. GEOMETRY: this band is a single row of character cells (unlike the teletext sibling's 6-row page) — sub-cell side = height / 3 px so exactly 3 sub-cell rows fill the band vertically; a character cell is 2 sub-cells wide x 3 tall, cellW = 2 * subCellSide; column count = floor(width / cellW). THE GAP IS THE ENTIRE IDENTITY: each lit sub-cell rect is inset by 18% of the sub-cell's side on all four edges (Math.round(subCellSide * 0.18), floored to a minimum of 1px so it never anti-aliases away at small card-scale cell sizes), drawn as literal unpainted --background between two --foreground/--ns-muted fills — never a --border stroke around the block (--border measures ~1.1:1 contrast in light theme and would make the gap structurally invisible exactly where it needs to prove separated mode differs from contiguous). CONTENT: each sub-cell's bit comes from a slow generative field — three non-commensurate traveling sine components summed over global sub-cell coordinates and the component's own continuously-advancing clock, normalized 0..1, thresholded at 0.5 — distinct frequency/phase constants from the teletext sibling's field so the two components' generative textures don't read as literal duplicates. ALIVE AT REST, THE MECHANIC: a write cursor sweeps left to right in fixed-width column-groups (GROUP_COLS=3 character cells per group), re-sampling the field into a fresh 6-bit pattern for every column in that group every COLUMN_INTERVAL_MS=90ms and holding it there until the group's next turn; the just-written group renders at full --foreground brightness for the duration of that 90ms window (tracked per-column via a write timestamp against the component's own continuously-advancing clock), then settles to the dimmer --ns-muted fill once its flash window elapses — a genuine two-tone identity, not a single flat color. After a full left-to-right pass (every column-group written once), an 800ms PAUSE_MS sync pause holds the completed pattern untouched before the sweep restarts from column 0 with a freshly re-sampled field state, so the visible mosaic content itself changes pass over pass, not just the cursor position. The component's clock never resets. On mount, and after every resize (a new column count leaves nothing painted otherwise), a full warm pass writes every column-group once at increasing clock time so the opening frame is never a blank band. RESTING LOOP: t0 is mid-sweep with roughly half the columns freshly written and the current column-group's flash visible at the leading edge, the rest of the band still blank background; 2.5s lands on a different sweep pass with content re-sampled from the field, either mid-sweep at a different column or inside the PAUSE_MS hold; 5s is a third distinct sweep/pause state, proving continuous unbounded cycling rather than a single reveal-and-stop. TOKENS: bg/fg/muted are read once from getComputedStyle(document.documentElement) against --background, --foreground, --ns-muted before the first paint, and re-derived on a documentElement class MutationObserver so a theme flip is live; --border is never read or used (unused per spec — it cannot carry the gap's job); --ns-accent never appears anywhere, this is a resting divider with zero interactive surface, no pointer stir, no hover state, and the write flash is a --foreground/--ns-muted luminance swap only, never accent-tinted. A ResizeObserver on the canvas re-measures and reflows on container size changes (not just window resize, since a divider's width is driven by its layout container); an IntersectionObserver pauses the rAF loop while the canvas is off-screen and resumes it (with a fresh last-timestamp so no giant delta-time jump plays on return) when it re-enters the viewport. prefers-reduced-motion freezes after running one additional full column-group write pass past the warm start (every column rewritten at least twice, field well clear of its cold-start state), then draws once with the step index parked in the sync-pause slot so no flash renders and every column is filled with the settled --ns-muted tone — the fully-written pass mid-PAUSE_MS hold, chosen because the separated-gap tile structure is legible edge to edge with nothing hidden, explicitly not t0's half-written sweep state. The render loop pauses on document.hidden and resumes cleanly on visibilitychange. Direct-DOM rAF, zero React state on the hot path, zero dependencies. 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. Props: height (band height px, default 42; sub-cell side derives as height / 3), className.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| height? | number | 42 | band height in px; sub-cell side is height / 3. Default 42. |
| className? | string | — | extra classes merged onto the rendered root element |