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
  • Mon18
  • Tue26
  • Wed31
  • Thu24
  • Fri7
  • Sat1
  • Sun0
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.

  • us-east-11,842
  • eu-west-11,310
  • ap-southeast-2664
  • sa-east-1212
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.

  • Jun+4.2
  • Jul+2.8
  • Aug-1.6
  • Sep-0.4
  • Oct+3.1
  • Nov+5.6
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.

  • auth92%
  • billing81%
  • search64%
  • webhooks47%
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

PropTypeDefaultWhat it does
data{ label: string; value: number; tone?: "ok" | "warn" | "danger" }[]noneOne bar per category, in display order. Negative values grow from a zero line.
labelstringnoneNames the list. The highest bar is added to it.
orientation"vertical" | "horizontal""vertical"Direction the bars grow.
showValuesbooleantrueShows each value by its bar. When off the values stay in the text for assistive tech.
maxnumbernoneFixes the end of the scale instead of using the largest value.
formatIntl.NumberFormatOptionsnoneHow values are written.
localestring"en-US"Passed to Intl.NumberFormat.
heightnumber96Plot height of a vertical chart, in pixels.
...restComponentProps<"ul">noneEvery native list attribute.

Anatomy

root.eq-bar-chartA list named by label; data-orientation and data-values set the layout.
item.eq-bar-chart-itemA focusable list item holding one category.
label.eq-bar-chart-label
track.eq-bar-chart-trackDraws the zero line.
bar.eq-bar-chart-barLifts to full tone on hover and focus.
value.eq-bar-chart-value

Keyboard

TabMoves 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index