← All components

BarChart

Horizontal bars that each reveal one shared gradient, so a bar's tip is its value.

  • Annual plans
    91%
  • Quarterly
    83%
  • Monthly
    74%
  • Trial converts
    58%

The idea

Every bar shows the same gradient across the track, cut off where the bar ends, so the colour at its tip is its value and the longest bar reaches the scale's far end. With negative values they grow either way from a centre baseline. Pointing at a row dims the others, which is the one thing a bar chart needs on hover. The bars grow out from the baseline, one after another, the first time they are seen.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

BarChart — usage12 lines
import { BarChart } from "mihirmodi-ui";

<BarChart
  rows={[
    { name: "Annual plans", value: 91, emphasis: true },
    { name: "Monthly", value: 74 },
  ]}
  format={(v) => `${v}%`}
/>

// Negative values grow left from a centre baseline.
<BarChart rows={[{ name: "Q1", value: 4.2 }, { name: "Q2", value: -1.8 }]} />

Props

PropNotes
rows{ name, value, emphasis? }[]Negative values grow left from a centre baseline.
digits / formatnumber / (v) => stringHow the figures at the ends are written.
unitstringBefore each figure.
good"high" | "low"Flip the scale for a metric where less is better.
--scale-low / --scale-mid / --scale-highCSS variablesThe value scale. Light grey to ink by default; redeclare the three stops for colour.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/BarChart.tsx98 lines
"use client";

import { useState } from "react";
import { SCALE, ends, formatter, niceMax, type Format, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";

export type BarRow = { name: string; value: number; emphasis?: boolean };

export type BarChartProps = {
  rows: BarRow[];
  digits?: number;
  format?: Format;
  unit?: string;
  good?: Good;
};

/**
 * Horizontal bars that reveal one shared gradient.
 *
 * Every bar shows the same gradient across the track, cut off where the bar
 * ends — so the colour at a bar's tip is its value, and the longest bar reaches
 * the scale's far end. With negative values the bars grow either way from a
 * centre baseline. Pointing at a row dims the others, which is the one thing a
 * bar chart needs on hover: which one you are reading.
 *
 * The bars grow out from the baseline the first time the chart is seen.
 */
export function BarChart({ rows, digits = 1, format, unit = "", good = "high" }: BarChartProps) {
  const f = formatter(digits, format);
  const { ref, drawn, animate } = useDrawIn<HTMLUListElement>();
  const [hover, setHover] = useState<number | null>(null);
  const hasNeg = rows.some((r) => r.value < 0);
  const top = Math.max(...rows.map((r) => Math.abs(r.value)));
  const maxAbs = niceMax(top * 1.05);
  // The gradient spans the longest bar, not the axis.
  const span = (v: number) => `${v > 0 ? (top / v) * 100 : 100}% 100%`;
  const [lo, hi] = ends(good);

  return (
    <ul ref={ref} className="flex flex-col gap-2.5">
      {rows.map((r, i) => {
        const pct = drawn ? (Math.abs(r.value) / maxAbs) * 100 : 0;
        const dim = hover === null || hover === i ? 1 : 0.55;
        const grow = {
          opacity: dim,
          transition: animate ? `width 700ms cubic-bezier(0.16,1,0.3,1) ${i * 60}ms, left 700ms cubic-bezier(0.16,1,0.3,1) ${i * 60}ms, opacity 200ms` : "opacity 200ms",
        };
        return (
          <li
            key={r.name}
            className="grid grid-cols-[minmax(0,42%)_1fr_auto] items-center gap-3"
            onMouseEnter={() => setHover(i)}
            onMouseLeave={() => setHover(null)}
          >
            <span className={`truncate text-[13px] ${r.emphasis ? "font-medium text-gray-1000" : "text-gray-900"}`}>{r.name}</span>
            <div className="relative h-5">
              {hasNeg ? (
                <>
                  <span aria-hidden className="absolute inset-y-0 left-1/2 w-px bg-gray-400" />
                  <span
                    className="absolute top-1/2 h-4 -translate-y-1/2"
                    style={{
                      ...grow,
                      width: `${pct / 2}%`,
                      left: r.value < 0 ? `calc(50% - ${pct / 2}%)` : "50%",
                      borderRadius: r.value < 0 ? "2px 0 0 2px" : "0 2px 2px 0",
                      backgroundImage: r.value < 0 ? `linear-gradient(to left, ${SCALE.mid}, ${SCALE.low})` : `linear-gradient(to right, ${SCALE.mid}, ${SCALE.high})`,
                      backgroundSize: span(Math.abs(r.value)),
                      backgroundPosition: r.value < 0 ? "right" : "left",
                      backgroundRepeat: "no-repeat",
                    }}
                  />
                </>
              ) : (
                <span
                  className="absolute left-0 top-1/2 h-4 -translate-y-1/2 rounded-r-[2px]"
                  style={{
                    ...grow,
                    width: `${pct}%`,
                    backgroundImage: `linear-gradient(to right, ${lo}, ${SCALE.mid}, ${hi})`,
                    backgroundPosition: "left",
                    backgroundRepeat: "no-repeat",
                    backgroundSize: span(Math.abs(r.value)),
                  }}
                />
              )}
            </div>
            <span className={`min-w-12 text-right text-[13px] tabular-nums ${r.emphasis ? "font-medium text-gray-1000" : "text-gray-900"}`}>
              {r.value < 0 ? "−" : ""}
              {unit}
              {f(Math.abs(r.value))}
            </span>
          </li>
        );
      })}
    </ul>
  );
}
packages/ui/src/lib/scale.ts54 lines
/**
 * The value scale every chart reads from.
 *
 * A quantity is a position between low and high, and its mark takes the scale
 * colour at that position — so the same value is the same colour in a bar, a
 * dot, a ring and a meter anywhere on the page. `good="low"` flips it for
 * metrics where less is better. A comparison series stays in the flat context
 * colour, so the subject and its reference never compete.
 *
 * The colours are mixed in CSS rather than computed here, so a theme flip (or
 * your own redeclared stops) re-colours every mark without a re-render.
 */

export type Good = "high" | "low";

export const SCALE = { low: "var(--scale-low)", mid: "var(--scale-mid)", high: "var(--scale-high)" } as const;
export const CONTEXT = "var(--chart-context)";
export const GRID = "var(--chart-grid)";

const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);

/** The colour at `t` ∈ [0, 1] on the low → mid → high scale, as a CSS value. */
export function scaleColor(t: number) {
  const u = clamp01(t);
  return u < 0.5
    ? `color-mix(in oklab, ${SCALE.low}, ${SCALE.mid} ${(u * 200).toFixed(1)}%)`
    : `color-mix(in oklab, ${SCALE.mid}, ${SCALE.high} ${((u - 0.5) * 200).toFixed(1)}%)`;
}

/** Where `v` sits between `min` and `max`, flipped when low is good. */
export function scaleT(v: number, min: number, max: number, good: Good = "high") {
  const t = max === min ? 0.5 : (v - min) / (max - min);
  return good === "low" ? 1 - t : t;
}

/** The scale's two ends, in the order a gradient should run for `good`. */
export const ends = (good: Good = "high") => (good === "low" ? [SCALE.high, SCALE.low] : [SCALE.low, SCALE.high]);

/** A round axis maximum above `v`: 1, 2, 2.5 or 5 times a power of ten. */
export function niceMax(v: number) {
  if (v <= 0) return 1;
  const p = Math.pow(10, Math.floor(Math.log10(v)));
  const n = v / p;
  return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p;
}

export type Format = (v: number) => string;

/** A number with at most `digits` decimals, in the reader's locale — unless you pass your own `format`. */
export function formatter(digits = 0, format?: Format): Format {
  if (format) return format;
  const nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: digits });
  return (v) => nf.format(v);
}