Bar chart whose fills are an ordered-dither halftone instead of a flat color — ink density tracks value as a second, redundant channel to height.
npx shadcn add https://design.helpmarq.com /r/chart-bar-halftone.jsonregistry/core/chart-bar-halftone/component.tsx"use client";
import { useEffect, useId, useMemo, useState } from "react";
import type { CSSProperties } from "react";
// ---------------------------------------------------------------------------
// ChartBarHalftone — the dithered-chart family's bar chart. Bar fills are not
// flat color: each bar is a plate stamped with the SAME 4x4 Bayer matrix used
// by background-ascii-dither and ascii-engraving-contour elsewhere in this
// suite (the family's shared ordered-dither ramp — 17 discrete ink levels,
// 0 = empty, 16 = solid), so a bar's height is redundant with its own ink
// density: cover one channel and the other still carries the value. Density
// is the family's only value channel — pure var(--foreground) ink on
// var(--background) paper, no --accent in the data itself, exactly like
// heatmap-year-stipple's choice. --accent is reserved for keyboard focus,
// same convention as every other component in the suite.
//
// Bars are SVG paths filled with `url(#pattern)`, one pattern per ink level,
// so var(--foreground)/var(--background)/var(--border) resolve as ordinary
// CSS custom properties on presentation attributes — no canvas, no
// getComputedStyle, no theme MutationObserver: the browser's own cascade
// repaints both themes correctly on toggle for free.
// ---------------------------------------------------------------------------
export interface ChartBarHalftoneDatum {
label: string;
value: number;
}
export interface ChartBarHalftoneProps {
data?: ChartBarHalftoneDatum[];
/** chart title, used as the figure's accessible name and table caption */
title?: string;
className?: string;
}
// 4x4 Bayer matrix — the family's shared dither constant, raw 0..15 ints
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
const LEVELS = 16; // 17 discrete steps, 0..16
const CELL = 4; // px per dither cell, shared pattern tile is 4x4 cells
const BAR_W = 22; // <=24px per the mark spec
const SLOT_W = 58;
const PLOT_H = 220;
const TOP_PAD = 34; // room for the tip label above the tallest bar
const AXIS_H = 8;
const LABEL_H = 22;
const LEFT_PAD = 12;
const RIGHT_PAD = 12;
function levelFor(norm: number): number {
return Math.round(Math.min(1, Math.max(0, norm)) * LEVELS);
}
function formatValue(v: number): string {
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (abs >= 1_000) return `${(v / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
return Math.round(v).toLocaleString();
}
/** rounded-top, square-baseline bar path, per the mark spec */
function barPath(x: number, yTop: number, yBase: number, w: number, r: number): string {
const rr = Math.min(r, w / 2, Math.max(0, yBase - yTop));
if (rr <= 0.01) return `M${x},${yBase} L${x},${yTop} L${x + w},${yTop} L${x + w},${yBase} Z`;
return [
`M${x},${yBase}`,
`L${x},${yTop + rr}`,
`Q${x},${yTop} ${x + rr},${yTop}`,
`L${x + w - rr},${yTop}`,
`Q${x + w},${yTop} ${x + w},${yTop + rr}`,
`L${x + w},${yBase}`,
`Z`,
].join(" ");
}
export function ChartBarHalftone({ data = [], title = "Chart", className = "" }: ChartBarHalftoneProps) {
const uid = useId().replace(/[:]/g, "");
const [activeIndex, setActiveIndex] = useState(0);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const [showTable, setShowTable] = useState(false);
const [entered, setEntered] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mq.matches) {
setEntered(true);
return;
}
const id = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(id);
}, []);
const n = data.length;
const maxValue = useMemo(() => Math.max(1, ...data.map((d) => d.value)), [data]);
const viewW = LEFT_PAD + n * SLOT_W + RIGHT_PAD;
const viewH = TOP_PAD + PLOT_H + AXIS_H + LABEL_H;
const baseY = TOP_PAD + PLOT_H;
const bars = useMemo(
() =>
data.map((d, i) => {
const norm = d.value / maxValue;
const x = LEFT_PAD + i * SLOT_W + (SLOT_W - BAR_W) / 2;
const h = PLOT_H * norm;
const yTop = baseY - h;
return { ...d, index: i, x, yTop, level: levelFor(norm) };
}),
[data, maxValue, baseY]
);
const focusBar = (i: number) => {
if (i < 0 || i >= n) return;
setActiveIndex(i);
document.getElementById(`${uid}-hit-${i}`)?.focus();
};
const hovered = hoverIndex ?? null;
return (
<figure className={`ns-cbh inline-block ${className}`} aria-label={`${title}, bar chart`}>
<style>{CSS}</style>
<div className="flex items-center justify-between gap-3 pb-2">
<span className="font-mono text-xs tracking-widest text-muted">{title.toUpperCase()}</span>
<button
type="button"
onClick={() => setShowTable((s) => !s)}
className="ns-cbh-toggle rounded-sm border border-border px-2 py-1 font-mono text-[10px] tracking-widest text-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-pressed={showTable}
>
{showTable ? "VIEW CHART" : "VIEW TABLE"}
</button>
</div>
{showTable ? (
<table className="ns-cbh-table w-full border-collapse font-mono text-xs">
<caption className="sr-only">{title}</caption>
<thead>
<tr>
<th scope="col" className="border-b border-border px-2 py-1.5 text-left text-muted">
Category
</th>
<th scope="col" className="border-b border-border px-2 py-1.5 text-right text-muted tabular-nums">
Value
</th>
</tr>
</thead>
<tbody>
{data.map((d) => (
<tr key={d.label}>
<td className="border-b border-border px-2 py-1.5 text-foreground">{d.label}</td>
<td className="border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums">
{d.value.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="relative">
<svg
viewBox={`0 0 ${viewW} ${viewH}`}
width={viewW}
style={{ maxWidth: "100%" }}
focusable="false"
role="presentation"
>
<defs>
{Array.from({ length: LEVELS + 1 }, (_, level) => (
<pattern
key={level}
id={`${uid}-p${level}`}
x={0}
y={0}
width={CELL * 4}
height={CELL * 4}
patternUnits="userSpaceOnUse"
>
{BAYER.map((b, i) =>
b < level ? (
<rect
key={i}
x={(i % 4) * CELL}
y={Math.floor(i / 4) * CELL}
width={CELL}
height={CELL}
fill="var(--foreground)"
/>
) : null
)}
</pattern>
))}
</defs>
{/* gridlines — hairline, recessive, no tick labels since every bar is direct-labeled */}
<g aria-hidden="true">
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line
key={f}
x1={LEFT_PAD}
x2={viewW - RIGHT_PAD}
y1={baseY - PLOT_H * f}
y2={baseY - PLOT_H * f}
stroke="var(--border)"
strokeWidth={1}
/>
))}
</g>
{bars.map((b) => {
const isHover = hovered === b.index;
const path = barPath(b.x, b.yTop, baseY, BAR_W, 4);
const cx = b.x + BAR_W / 2;
return (
<g
key={b.label}
className="ns-cbh-bar"
aria-hidden="true"
style={
{
transformOrigin: `${cx}px ${baseY}px`,
transform: entered ? "scaleY(1)" : "scaleY(0)",
transitionDelay: `${b.index * 45}ms`,
} as CSSProperties
}
>
<path d={path} fill={`url(#${uid}-p${b.level})`} opacity={isHover ? 1 : 0.92} />
<text
x={cx}
y={b.yTop - 8}
textAnchor="middle"
className="font-mono"
style={{ fontSize: 9.5, fill: "var(--foreground)" }}
>
{formatValue(b.value)}
</text>
<text
x={cx}
y={baseY + AXIS_H + LABEL_H - 7}
textAnchor="middle"
className="font-mono"
style={{ fontSize: 9.5, fill: "var(--muted)" }}
>
{b.label}
</text>
</g>
);
})}
<line
x1={LEFT_PAD}
x2={viewW - RIGHT_PAD}
y1={baseY}
y2={baseY}
stroke="var(--border)"
strokeWidth={1}
aria-hidden="true"
/>
{/* hit targets — real interactive elements, sized past the bar's own
painted pixels per the >=24px hit-area rule */}
{bars.map((b) => (
<rect
key={`hit-${b.label}`}
id={`${uid}-hit-${b.index}`}
role="button"
tabIndex={activeIndex === b.index ? 0 : -1}
aria-label={`${b.label}: ${b.value.toLocaleString()}`}
x={b.x + BAR_W / 2 - SLOT_W / 2}
y={TOP_PAD - 12}
width={SLOT_W}
height={PLOT_H + 12}
fill="transparent"
className="ns-cbh-hit"
onPointerEnter={() => setHoverIndex(b.index)}
onPointerLeave={() => setHoverIndex((c) => (c === b.index ? null : c))}
onFocus={() => {
setActiveIndex(b.index);
setHoverIndex(b.index);
}}
onBlur={() => setHoverIndex((c) => (c === b.index ? null : c))}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
focusBar(b.index - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
focusBar(b.index + 1);
}
}}
/>
))}
</svg>
{hovered !== null && bars[hovered] && (
<div
aria-hidden="true"
className="ns-cbh-tip pointer-events-none absolute z-10 rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] shadow-sm"
style={{
left: `${((bars[hovered].x + BAR_W / 2) / viewW) * 100}%`,
top: `${(Math.max(0, bars[hovered].yTop - 34) / viewH) * 100}%`,
transform: "translateX(-50%)",
}}
>
<strong className="text-foreground">{formatValue(bars[hovered].value)}</strong>{" "}
<span className="text-muted">{bars[hovered].label}</span>
</div>
)}
</div>
)}
</figure>
);
}
const CSS = `
.ns-cbh-bar { transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1); }
.ns-cbh-hit { cursor: pointer; outline: none; }
.ns-cbh-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.ns-cbh-bar { transition: none; }
}
`;
comparing values across nominal categories (products, teams, regions) where ink density should carry the value redundantly with bar height, so the chart survives a monochrome print or a colorblind read; pick chart-donut-halftone instead when the data is part-to-whole over an ordinal scale rather than a comparison across categories, or histogram-live-grain when the bars would be bins of a live sample rather than fixed categories.
A dithered-chart-family bar chart: the first member alongside chart-donut-halftone, both stamped from the same 4x4 Bayer matrix already used by background-ascii-dither and ascii-engraving-contour elsewhere in this suite, so the aesthetic those two components established stays a single, shared constant rather than three independent reimplementations. Bars are thin (22px, under the 24px mark-spec cap), 4px-rounded at the data end and square at the baseline, and instead of a flat fill each bar is an SVG path filled with one of 17 precomputed patterns (levels 0-16): pattern N tiles the Bayer matrix's 16 cells at 4px each and inks every cell whose Bayer value is below N, so a bar's own ink density is a second, independently-legible encoding of its value — cover the bar's height and the halftone alone still tells you roughly how full it is. This is the family's colour decision made explicit: density is the only value channel here, pure var(--foreground) ink on var(--background) paper exactly like heatmap-year-stipple's precedent, with var(--accent) reserved for keyboard focus only, never for data. Every fill, gridline, and border is a CSS custom property referenced directly as an SVG presentation-attribute value (fill="var(--foreground)"), so both themes repaint correctly on toggle with no getComputedStyle call and no MutationObserver — unlike the canvas-based components in this family, plain SVG lets the browser's own cascade do that work. Hairline gridlines mark 0/25/50/75/100% with no tick labels, because every bar already carries a direct value label at its tip (a rounded compact figure, 1.2K style) per the mark spec's own rule that axis ticks are dropped once every value is labeled; a category label sits below the baseline. Each bar has an invisible hit rectangle sized to its full slot (wider than the painted bar, per the >=24px hit-area rule) carrying role=button, an aria-label of "label: value", and roving tabindex — ArrowLeft/ArrowRight move focus between bars, and hover or focus both raise a small value+label tooltip positioned above the bar without gating any information (the same fact is already in the aria-label and in the table view). A VIEW TABLE toggle swaps the chart for a real HTML table with the same data, the accessibility twin required for a continuous value scale. On mount, bars grow from the baseline on a 480ms ease-out transform, staggered 45ms per bar; prefers-reduced-motion skips the stagger and renders bars at full height immediately. Zero dependencies, pure SVG.