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.
npx shadcn add https://design.helpmarq.com /r/progress-hatch.jsonregistry/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>
);
}
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.
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.