Charts
Bar chart
Vertical or horizontal bars comparing a handful of categories, with real text labels and focusable bars.
- Built on
CSS gridsubgridcolor-mix()Intl.NumberFormat- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.1 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { BarChart } from "@/components/equip/bar-chart";
const deploys = [
{ label: "Mon", value: 18 },
{ label: "Tue", value: 26 },
{ label: "Wed", value: 31 },
{ label: "Thu", value: 24 },
{ label: "Fri", value: 7, tone: "warn" as const },
{ label: "Sat", value: 1 },
{ label: "Sun", value: 0 },
];
export default function Demo() {
return <BarChart data={deploys} label="Deploys per weekday, last 4 weeks" height={72} style={{ width: 216 }} />;
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Deploys per weekday, last 4 weeks. Highest: Wed, 31" class="eq-bar-chart" data-orientation="vertical" data-values="shown" style="width:216px;--_zero:0;--_h:72px">
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.5806451612903226">
<span class="eq-bar-chart-label">Mon</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">18</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.8387096774193549">
<span class="eq-bar-chart-label">Tue</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">26</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:1">
<span class="eq-bar-chart-label">Wed</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">31</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.7741935483870968">
<span class="eq-bar-chart-label">Thu</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">24</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="warn" style="--_at:0;--_v:0.22580645161290322">
<span class="eq-bar-chart-label">Fri</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">7</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.03225806451612903">
<span class="eq-bar-chart-label">Sat</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">1</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0">
<span class="eq-bar-chart-label">Sun</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">0</span>
</li>
</ul>Examples
Horizontal with tones
Labels are real text in a grid column, so long category names line up; tone colours the bar only, so say why in the label too.
Code
import { BarChart } from "@/components/equip/bar-chart";
const regions = [
{ label: "us-east-1", value: 1842 },
{ label: "eu-west-1", value: 1310 },
{ label: "ap-southeast-2", value: 664, tone: "warn" as const },
{ label: "sa-east-1", value: 212, tone: "danger" as const },
];
export default function Example() {
return (
<BarChart
data={regions}
label="Requests per second by region; ap-southeast-2 and sa-east-1 are under target"
orientation="horizontal"
style={{ maxWidth: 360 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Requests per second by region; ap-southeast-2 and sa-east-1 are under target. Highest: us-east-1, 1,842" class="eq-bar-chart" data-orientation="horizontal" data-values="shown" style="max-width:360px;--_zero:0;--_h:96px">
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:1">
<span class="eq-bar-chart-label">us-east-1</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">1,842</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.7111834961997828">
<span class="eq-bar-chart-label">eu-west-1</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">1,310</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="warn" style="--_at:0;--_v:0.36047774158523344">
<span class="eq-bar-chart-label">ap-southeast-2</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">664</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="danger" style="--_at:0;--_v:0.11509229098805646">
<span class="eq-bar-chart-label">sa-east-1</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">212</span>
</li>
</ul>Negative values
The zero line moves to fit the range, and bars below it grow downwards.
Code
import { BarChart } from "@/components/equip/bar-chart";
const change = [
{ label: "Jun", value: 4.2 },
{ label: "Jul", value: 2.8 },
{ label: "Aug", value: -1.6, tone: "danger" as const },
{ label: "Sep", value: -0.4, tone: "danger" as const },
{ label: "Oct", value: 3.1 },
{ label: "Nov", value: 5.6, tone: "ok" as const },
];
export default function Example() {
return (
<BarChart
data={change}
label="Net seat change, percent per month"
format={{ maximumFractionDigits: 1, signDisplay: "exceptZero" }}
height={120}
style={{ maxWidth: 320 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Net seat change, percent per month. Highest: Nov, +5.6" class="eq-bar-chart" data-orientation="vertical" data-values="shown" style="max-width:320px;--_zero:0.22222222222222227;--_h:120px">
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0.22222222222222227;--_v:0.5833333333333334">
<span class="eq-bar-chart-label">Jun</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">+4.2</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0.22222222222222227;--_v:0.3888888888888889">
<span class="eq-bar-chart-label">Jul</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">+2.8</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="danger" data-negative="true" style="--_at:0;--_v:0.22222222222222227">
<span class="eq-bar-chart-label">Aug</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">-1.6</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="danger" data-negative="true" style="--_at:0.1666666666666667;--_v:0.055555555555555566">
<span class="eq-bar-chart-label">Sep</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">-0.4</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0.22222222222222227;--_v:0.43055555555555564">
<span class="eq-bar-chart-label">Oct</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">+3.1</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="ok" style="--_at:0.22222222222222227;--_v:0.7777777777777778">
<span class="eq-bar-chart-label">Nov</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">+5.6</span>
</li>
</ul>Fixed scale, values on focus
max keeps a percent chart honest at 100, and with showValues off each value is still read when its bar is focused.
Code
import { BarChart } from "@/components/equip/bar-chart";
const coverage = [
{ label: "auth", value: 0.92 },
{ label: "billing", value: 0.81 },
{ label: "search", value: 0.64 },
{ label: "webhooks", value: 0.47, tone: "warn" as const },
];
export default function Example() {
return (
<BarChart
data={coverage}
label="Test coverage by package"
orientation="horizontal"
max={1}
showValues={false}
format={{ style: "percent" }}
style={{ maxWidth: 320 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Test coverage by package. Highest: auth, 92%" class="eq-bar-chart" data-orientation="horizontal" data-values="hidden" style="max-width:320px;--_zero:0;--_h:96px">
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.92">
<span class="eq-bar-chart-label">auth</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">92%</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.81">
<span class="eq-bar-chart-label">billing</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">81%</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" style="--_at:0;--_v:0.64">
<span class="eq-bar-chart-label">search</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">64%</span>
</li>
<li class="eq-bar-chart-item" tabindex="0" data-tone="warn" style="--_at:0;--_v:0.47">
<span class="eq-bar-chart-label">webhooks</span>
<span class="eq-bar-chart-track" aria-hidden="true">
<span class="eq-bar-chart-bar"></span>
</span>
<span class="eq-bar-chart-value">47%</span>
</li>
</ul>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
data | { label: string; value: number; tone?: "ok" | "warn" | "danger" }[] | none | One bar per category, in display order. Negative values grow from a zero line. |
label | string | none | Names the list. The highest bar is added to it. |
orientation | "vertical" | "horizontal" | "vertical" | Direction the bars grow. |
showValues | boolean | true | Shows each value by its bar. When off the values stay in the text for assistive tech. |
max | number | none | Fixes the end of the scale instead of using the largest value. |
format | Intl.NumberFormatOptions | none | How values are written. |
locale | string | "en-US" | Passed to Intl.NumberFormat. |
height | number | 96 | Plot height of a vertical chart, in pixels. |
...rest | ComponentProps<"ul"> | none | Every native list attribute. |
Anatomy
| root | .eq-bar-chart | A list named by label; data-orientation and data-values set the layout. |
|---|---|---|
| item | .eq-bar-chart-item | A focusable list item holding one category. |
| label | .eq-bar-chart-label | |
| track | .eq-bar-chart-track | Draws the zero line. |
| bar | .eq-bar-chart-bar | Lifts to full tone on hover and focus. |
| value | .eq-bar-chart-value |
Keyboard
| Tab | Moves through the bars; each one announces its label and value. |
|---|
Notes
- It is a list rather than an image, so labels are real text and each bar can be focused and read as its label and value.
- Each bar is a tab stop, which suits a handful of categories; for long series use a Sparkline or a Table.
- Tone colours a bar only, so say what it means in the label or nearby text as well.
- Renders on the server with no client JavaScript.
- The horizontal layout uses subgrid; where it is missing, labels, bars and values still line up within each row but not across rows.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/bar-chart.tsx75 lines
import type { CSSProperties, ComponentProps } from "react";
import "./bar-chart.css";
export type BarDatum = { label: string; value: number; tone?: "ok" | "warn" | "danger" };
export type BarChartProps = ComponentProps<"ul"> & {
/** One bar per category, in display order. */
data: BarDatum[];
/** Names the chart, such as "Deploys per weekday, last 4 weeks". The highest bar is added to it. */
label: string;
orientation?: "vertical" | "horizontal";
/** Shows each value next to its bar. Values are always in the text for assistive tech. */
showValues?: boolean;
/** Fixes the end of the scale instead of using the largest value. */
max?: number;
format?: Intl.NumberFormatOptions;
locale?: string;
/** Height of the plot area in a vertical chart, in pixels. */
height?: number;
};
export function BarChart({
data,
label,
orientation = "vertical",
showValues = true,
max,
format,
locale = "en-US",
height = 96,
className,
style,
...rest
}: BarChartProps) {
const number = new Intl.NumberFormat(locale, format);
const values = data.map((d) => (Number.isFinite(d.value) ? d.value : 0));
const hi = Math.max(0, max ?? -Infinity, ...values);
const lo = Math.min(0, ...values);
const range = hi - lo || 1;
const top = data.reduce<BarDatum | undefined>((best, d) => (!best || d.value > best.value ? d : best), undefined);
const name =
top && data.length > 1 ? `${label}. Highest: ${top.label}, ${number.format(top.value)}` : label;
return (
<ul
role="list"
aria-label={name}
{...rest}
className={["eq-bar-chart", className].filter(Boolean).join(" ")}
data-orientation={orientation}
data-values={showValues ? "shown" : "hidden"}
style={{ ...style, "--_zero": -lo / range, "--_h": `${height}px` } as CSSProperties}
>
{data.map((d, i) => {
const v = values[i] ?? 0;
return (
<li
key={`${d.label}-${i}`}
className="eq-bar-chart-item"
tabIndex={0}
data-tone={d.tone}
data-negative={v < 0 || undefined}
style={{ "--_at": (Math.min(v, 0) - lo) / range, "--_v": Math.abs(v) / range } as CSSProperties}
>
<span className="eq-bar-chart-label">{d.label}</span>
<span className="eq-bar-chart-track" aria-hidden="true">
<span className="eq-bar-chart-bar" />
</span>
<span className="eq-bar-chart-value">{number.format(v)}</span>
</li>
);
})}
</ul>
);
}components/equip/bar-chart.css174 lines
@layer equip {
.eq-bar-chart {
margin: 0;
padding: 0;
list-style: none;
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
color: var(--eq-ink-2);
}
.eq-bar-chart[data-orientation="horizontal"] {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
gap: var(--eq-space-1) var(--eq-space-2);
}
.eq-bar-chart[data-orientation="vertical"] {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: var(--eq-space-2);
}
.eq-bar-chart-item {
--_c: var(--eq-ink);
display: grid;
align-items: center;
min-height: 24px;
border-radius: var(--eq-radius-s);
outline: none;
cursor: default;
}
.eq-bar-chart-item[data-tone="ok"] {
--_c: var(--eq-ok);
}
.eq-bar-chart-item[data-tone="warn"] {
--_c: var(--eq-warn);
}
.eq-bar-chart-item[data-tone="danger"] {
--_c: var(--eq-danger);
}
.eq-bar-chart[data-orientation="horizontal"] .eq-bar-chart-item {
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-item {
grid-template-rows: auto var(--_h) auto;
justify-items: center;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-bar-chart-label {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--eq-dur) var(--eq-ease);
}
.eq-bar-chart-value {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-value {
grid-row: 1;
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-track {
grid-row: 2;
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-label {
grid-row: 3;
}
.eq-bar-chart[data-values="hidden"] .eq-bar-chart-value {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-bar-chart-track {
position: relative;
display: block;
width: 100%;
}
.eq-bar-chart[data-orientation="horizontal"] .eq-bar-chart-track {
height: 10px;
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-track {
height: 100%;
}
/* The zero line. Between bars it reaches across the gap, so it reads as one axis. */
.eq-bar-chart-track::before {
content: "";
position: absolute;
background: var(--eq-line-strong);
}
.eq-bar-chart[data-orientation="horizontal"] .eq-bar-chart-track::before {
inset-block: calc(var(--eq-space-1) / -2);
inset-inline-start: calc(var(--_zero) * 100%);
width: 1px;
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-track::before {
inset-inline: calc(var(--eq-space-2) / -2);
bottom: calc(var(--_zero) * 100%);
height: 1px;
}
.eq-bar-chart-bar {
position: absolute;
border-radius: calc(var(--eq-radius-s) / 2);
background: color-mix(in srgb, var(--_c) 72%, transparent);
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-bar-chart[data-orientation="horizontal"] .eq-bar-chart-bar {
inset-block: 0;
inset-inline-start: calc(var(--_at) * 100%);
width: calc(var(--_v) * 100%);
}
.eq-bar-chart[data-orientation="vertical"] .eq-bar-chart-bar {
left: 50%;
bottom: calc(var(--_at) * 100%);
width: min(100%, 28px);
height: calc(var(--_v) * 100%);
translate: -50% 0;
}
@media (hover: hover) {
.eq-bar-chart-item:hover .eq-bar-chart-bar {
background: var(--_c);
}
.eq-bar-chart-item:hover .eq-bar-chart-label {
color: var(--eq-ink);
}
}
.eq-bar-chart-item:focus-visible .eq-bar-chart-bar {
background: var(--_c);
}
.eq-bar-chart-item:focus-visible .eq-bar-chart-label {
color: var(--eq-ink);
}
.eq-bar-chart-item:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
}