Determinate/indeterminate loader built from braille dot patterns, where each cell's eight dots are individually addressable for far finer granularity than a block-character bar.
npx shadcn add https://design.helpmarq.com /r/loader-braille.jsonregistry/core/loader-braille/component.tsx"use client";
// A loader built from braille cells (U+2800 block), where each cell's eight
// dots are individually addressable bits rather than a single glyph from a
// spinner sprite sheet. Indeterminate: a wave travels through the row — each
// cell's fill level is a sine function of time, phase-shifted by its column,
// so dots rise and fall like a stadium wave rather than a rotating glyph.
// Determinate: `progress` maps onto every dot across the row in reading
// order (cell 0's eight dots fill first, then cell 1's, ...), so 0-100% has
// N_CELLS * 8 addressable steps — far finer than a block-character bar could
// offer at the same character width. Reaching 100 triggers a one-shot accent
// pulse across the row before settling.
//
// One direct-DOM rAF loop builds the row string and writes it to a single
// ref's textContent each frame (never React state per frame); glyph color is
// read from getComputedStyle so it tracks the surrounding theme. Settling
// on completion is the only thing that goes through React state, since it's
// a discrete one-shot transition, not a per-frame value.
import { useEffect, useId, useRef, useState } from "react";
const N_CELLS = 14;
// dot fill priority within a cell, bottom row first: rises like a level meter
const PRIORITY = [7, 8, 3, 6, 2, 5, 1, 4] as const;
const BIT: Record<number, number> = { 1: 0x01, 2: 0x02, 3: 0x04, 4: 0x08, 5: 0x10, 6: 0x20, 7: 0x40, 8: 0x80 };
function cellChar(n: number): string {
const count = Math.max(0, Math.min(8, Math.round(n)));
let mask = 0;
for (let i = 0; i < count; i++) mask |= BIT[PRIORITY[i]];
return String.fromCharCode(0x2800 + mask);
}
const WAVE_SPEED = 2.6; // rad/s
const PHASE_STEP = 0.55; // rad per column
const SETTLE_MS = 480;
export interface BrailleSpinProps {
/** 0-100 for a determinate fill; omit (or leave undefined) for the indeterminate wave. */
progress?: number;
/** accessible label for the progressbar. */
"aria-label"?: string;
className?: string;
}
export function BrailleSpin({
progress,
"aria-label": ariaLabel = "Loading",
className = "",
}: BrailleSpinProps) {
const rowRef = useRef<HTMLSpanElement>(null);
const progressRef = useRef(progress);
const [settled, setSettled] = useState(progress != null && progress >= 100);
const settledOnceRef = useRef(settled);
const idBase = useId();
useEffect(() => {
progressRef.current = progress;
}, [progress]);
useEffect(() => {
if (progress != null && progress >= 100) {
if (!settledOnceRef.current) {
settledOnceRef.current = true;
setSettled(true);
const t = window.setTimeout(() => setSettled(false), SETTLE_MS);
return () => window.clearTimeout(t);
}
} else {
settledOnceRef.current = false;
}
}, [progress]);
// mount-once animated loop; skipped entirely under reduced motion
useEffect(() => {
const row = rowRef.current;
if (!row) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const paint = (levels: number[]) => {
row.textContent = levels.map(cellChar).join("");
};
let raf = 0;
const start = performance.now();
const loop = (now: number) => {
const p = progressRef.current;
if (p == null) {
const t = (now - start) / 1000;
paint(
Array.from({ length: N_CELLS }, (_, i) => 4 + 4 * Math.sin(t * WAVE_SPEED - i * PHASE_STEP))
);
} else {
const totalDots = N_CELLS * 8;
const litDots = Math.round((Math.max(0, Math.min(100, p)) / 100) * totalDots);
paint(Array.from({ length: N_CELLS }, (_, i) => litDots - i * 8));
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// reduced motion: one correct static frame, repainted live if `progress` changes
// (there is no running loop above to pick that change up on its own)
useEffect(() => {
const row = rowRef.current;
if (!row) return;
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (progress == null) {
// gentle standing arc stands in for the wave with nothing moving
row.textContent = Array.from({ length: N_CELLS }, (_, i) => cellChar(4 + 2 * Math.sin(i * PHASE_STEP))).join("");
} else {
const totalDots = N_CELLS * 8;
const litDots = Math.round((Math.max(0, Math.min(100, progress)) / 100) * totalDots);
row.textContent = Array.from({ length: N_CELLS }, (_, i) => cellChar(litDots - i * 8)).join("");
}
}, [progress]);
const determinate = progress != null;
const clamped = determinate ? Math.max(0, Math.min(100, progress as number)) : undefined;
return (
<div
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={determinate ? 0 : undefined}
aria-valuemax={determinate ? 100 : undefined}
aria-valuenow={determinate ? Math.round(clamped as number) : undefined}
className={`inline-block font-mono leading-none ${className}`}
>
<style>{`
.ns-braille-row{transition:color 260ms ease-out}
.ns-braille-settled{color:var(--accent) !important}
`}</style>
<span
ref={rowRef}
id={idBase}
aria-hidden
className={`ns-braille-row whitespace-pre text-foreground ${settled ? "ns-braille-settled" : ""}`}
>
{cellChar(0).repeat(N_CELLS)}
</span>
</div>
);
}
a loader where the glyph itself is the data structure — braille cells whose eight dots are addressable bits give per-dot granularity across a row, and the indeterminate state is a genuine travelling wave rather than a rotating spinner sprite. Pick progress-narrated or loader-thread-spool instead when the loader needs a narrated ledger or a real elapsed-time-honest indeterminate metaphor.
A loader rendered as a row of braille cells (U+2800 block), where the eight dots of each cell are addressable bits rather than a spinner glyph pulled from a fixed set. A cell's fill count 0-8 is converted to a character via a fixed per-cell dot priority list `[7,8,3,6,2,5,1,4]` (bottom row first, so a partially-filled cell reads as a rising level meter rather than a scattered dot cluster) OR'd into the bitmask added to 0x2800. Indeterminate (`progress` undefined): each of the 14 cells' fill level is `4 + 4*sin(t*2.6 - i*0.55)`, i.e. one continuous sine sampled with a per-column phase offset, so the wave genuinely travels left to right across the row with dots rising and falling like a stadium wave, not a rotating sprite. Determinate (`progress` 0-100): the value maps onto all 14*8=112 dots in reading order — cell 0's eight dots fill before cell 1's — giving well over an order of magnitude finer resolution than a block-character bar could offer at the same character width; reaching 100 triggers a one-shot ~480ms accent-color pulse across the row (a discrete React state transition, the only state change in the whole animation) before it settles back to foreground ink. Everything else is a single direct-DOM rAF loop that builds the full row string each frame and writes it once to a ref's textContent — never per-frame React state — reading glyph color from the surrounding `text-foreground`/`--accent` tokens so it works unmodified in both themes. `role=progressbar` carries `aria-valuemin/max/now` only in the determinate case (per spec, an indeterminate progressbar omits `aria-valuenow` rather than reporting a fake value); the glyph row itself is `aria-hidden` since the numeric state is exposed through the ARIA attributes, not by parsing braille. prefers-reduced-motion renders one correct static frame — a gentle standing arc for the indeterminate case, the literal frame implied by `progress` for the determinate one — and a separate light effect keyed on `progress` keeps that static frame in sync if the value changes while reduced motion is on, since the animated loop (which would otherwise pick that up) never starts.