Loader Braille

Loader

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.

Install
npx shadcn add https://design.helpmarq.com/r/loader-braille.json
Source
registry/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>
  );
}
Use when

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.

Build spec

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.

Tags
loaderprogressasciibraillemono