Progress Hatch

Progress

Engineering-drawing progress meter: a light hatch track and a dense shade-ramp fill with a dithered leading edge, an inline right-aligned numeric readout, and a box-drawing ruler of ticks below.

Install
npx shadcn add https://design.helpmarq.com/r/progress-hatch.json
Source
registry/core/progress-hatch/component.tsx
"use client";

// A progress meter in the engineering-drawing register: the track is a run
// of a single light hatch glyph (░), the filled portion is solid (█), and
// the boundary between them is not a hard cut — a few columns of ordered
// dither (a 1D analogue of the Bayer matrix used elsewhere in this suite)
// blend ░▒▓█ across the leading edge so the fill reads as a texture gradient
// rather than a flat block edge. The numeric readout is not a separate
// element floating over the bar: it is printed into the same character grid,
// right-aligned, overwriting whichever hatch/fill glyphs would otherwise sit
// in those cells. Tick marks and labels sit on a second and third monospace
// line below, drawn as a box-drawing ruler (├──┬──┬──┬──┤).
//
// `value` glides toward its target over ~420ms via a single direct-DOM rAF
// loop that writes the built row string straight to a ref's textContent —
// never per-frame React state — and sleeps once the ease settles.

import { useEffect, useRef } from "react";

const RAMP = ["░", "▒", "▓", "█"] as const;
// 1D ordered-dither sequence (a Bayer-matrix analogue in one dimension):
// comparing a column's local edge position against its own threshold here,
// rather than a smooth density ramp, gives the edge visible grain instead of
// a clean gradient.
const DITHER = [0.15, 0.65, 0.35, 0.85, 0.5, 0.05, 0.95, 0.45];
const EDGE_COLS = 4; // width, in characters, of the dithered leading edge
const GLIDE_MS = 420;

function easeOutCubic(p: number): number {
  return 1 - (1 - p) ** 3;
}

function glyphAt(col: number, fillCols: number): string {
  const coverage = fillCols - col; // how far this column sits behind the fill edge
  if (coverage >= EDGE_COLS) return RAMP[3]; // "█", solidly filled
  if (coverage <= 0) return "░"; // untouched track
  const local = coverage / EDGE_COLS; // 0..1 through the dithered edge
  const threshold = DITHER[col % DITHER.length];
  const level = local > threshold ? Math.floor(local * 4) + 1 : Math.floor(local * 4);
  return RAMP[Math.max(0, Math.min(3, level))];
}

function buildBarRow(displayValue: number, totalChars: number, readoutWidth: number): string {
  const fillCols = (Math.max(0, Math.min(100, displayValue)) / 100) * totalChars;
  const chars: string[] = [];
  for (let col = 0; col < totalChars; col++) chars.push(glyphAt(col, fillCols));
  const numText = `${String(Math.round(displayValue)).padStart(readoutWidth - 1, " ")}%`;
  for (let i = 0; i < numText.length; i++) {
    chars[totalChars - numText.length + i] = numText[i];
  }
  return chars.join("");
}

function buildTicks(totalChars: number, marks: number[]): { tickLine: string; labelLine: string } {
  const tick = new Array(totalChars).fill("─");
  const label = new Array(totalChars).fill(" ");
  const cols = marks.map((m) => Math.round((Math.max(0, Math.min(100, m)) / 100) * (totalChars - 1)));
  cols.forEach((col, i) => {
    tick[col] = "┬";
    const text = String(marks[i]);
    let start = col - Math.floor((text.length - 1) / 2);
    start = Math.max(0, Math.min(totalChars - text.length, start));
    for (let k = 0; k < text.length; k++) label[start + k] = text[k];
  });
  tick[0] = "├";
  tick[totalChars - 1] = "┤";
  return { tickLine: tick.join(""), labelLine: label.join("") };
}

export interface HatchFillProps {
  /** progress, 0-100 (controlled). */
  value: number;
  /** bar width in characters. */
  totalChars?: number;
  /** percentages to tick and label below the bar. */
  marks?: number[];
  /** accessible label for the meter. */
  "aria-label"?: string;
  className?: string;
}

export function HatchFill({
  value,
  totalChars = 44,
  marks = [0, 25, 50, 75, 100],
  "aria-label": ariaLabel = "Progress",
  className = "",
}: HatchFillProps) {
  const rowRef = useRef<HTMLSpanElement>(null);
  const valueRef = useRef(value);
  const displayRef = useRef(value);
  const fromRef = useRef(value);
  const startRef = useRef(-1);
  const rafRef = useRef(0);
  const readoutWidth = 4; // " 37%" / "100%" — fixed width so digits never jitter the grid

  const { tickLine, labelLine } = buildTicks(totalChars, marks);

  useEffect(() => {
    const row = rowRef.current;
    if (!row) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const paint = () => {
      row.textContent = buildBarRow(displayRef.current, totalChars, readoutWidth);
    };

    const loop = (now: number) => {
      const p = Math.min(1, (now - startRef.current) / GLIDE_MS);
      displayRef.current = fromRef.current + (valueRef.current - fromRef.current) * easeOutCubic(p);
      paint();
      if (p < 1) {
        rafRef.current = requestAnimationFrame(loop);
      } else {
        rafRef.current = 0;
      }
    };

    if (reduced) {
      displayRef.current = value;
      paint();
    } else if (value !== displayRef.current) {
      fromRef.current = displayRef.current;
      startRef.current = performance.now();
      if (!rafRef.current) rafRef.current = requestAnimationFrame(loop);
    }
    valueRef.current = value;

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      // clear the id, not just the frame: the restart guard below tests
      // `!rafRef.current`, so leaving a stale id here permanently wedges the
      // glide the first time a new value arrives mid-animation.
      rafRef.current = 0;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- rAF reads valueRef/displayRef live
  }, [value, totalChars]);

  const clamped = Math.max(0, Math.min(100, value));

  return (
    <div
      className={`inline-block font-mono leading-[1.5] text-foreground ${className}`}
      style={{ width: `${totalChars}ch` }}
    >
      <span
        ref={rowRef}
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(clamped)}
        aria-label={ariaLabel}
        className="block whitespace-pre text-foreground"
      >
        {buildBarRow(value, totalChars, readoutWidth)}
      </span>
      <span aria-hidden className="block whitespace-pre text-border">
        {tickLine}
      </span>
      <span aria-hidden className="block whitespace-pre text-[0.85em] text-muted">
        {labelLine}
      </span>
    </div>
  );
}
Use when

a progress meter drawn as a blueprint-style hatch ruler — a dithered ░▒▓█ leading edge instead of a flat fill boundary, a numeric readout printed into the same character grid rather than overlaid, and a labelled box-drawing tick ruler underneath. Pick progress-narrated instead for a bar that narrates named phases, or loader-braille for per-dot rather than per-character resolution.

Build spec

A determinate progress meter in the engineering-drawing register, three stacked monospace lines: the bar itself, a box-drawing tick ruler, and a label row. The bar's track is a run of the single light hatch glyph ░; the filled run is solid █; between them is a fixed-width (4 column) dithered edge where each column's fill level is decided not by a smooth density ramp but by comparing that column's local position through the edge against its own entry in an 8-value ordered-dither sequence — a 1D analogue of the Bayer matrix used in background-ascii-dither elsewhere in this suite — so the boundary reads as grain/texture rather than a flat cut or a clean gradient. The numeric readout is not a floating overlay: it is printed directly into the same character array, right-aligned, overwriting whatever hatch or fill glyphs would otherwise occupy those trailing cells, at a fixed 4-column width (' 37%' / '100%') so the grid never jitters as the digit count changes. Below the bar, a second line draws a box-drawing ruler (├──┬──┬──┬──┤) with a ┬ at each `marks` percentage (default 0/25/50/75/100) and a third line centers that mark's number underneath, clipped to the grid bounds at the ends. `value` (0-100, controlled) glides toward its target over a fixed 420ms ease-out-cubic via a single direct-DOM rAF loop that rebuilds the bar row string each frame and writes it straight to a ref's textContent — never per-frame React state — and sleeps once the ease settles; the tick/label lines are static per render and need no loop. `role=progressbar` with aria-valuemin/max/now sits on the bar line itself, aria-label supplied by the caller. Colors are `text-foreground` for the bar, `text-border` for the tick ruler and `text-muted` for labels — no hardcoded hex, so both themes render correctly. prefers-reduced-motion (read live via matchMedia on mount and on every value change) skips the glide and paints the exact frame implied by `value` immediately. The container is sized to `${totalChars}ch` with an explicit line-height so the character grid holds its width regardless of font metrics, and stays legible at small sizes because the hatch ramp only has four density levels rather than a continuous gradient.

Tags
progressmeterasciihatchmonoengineering