CSS data marks
Diverging bar, unit grid and dumbbell, drawn in DOM and CSS so they fit any column
npx shadcn@latest add https://ethankd.com/r/data-marks.jsonSample row
+5.4pp
Sample row
+1.1pp
Sample row
−0.6pp
Sample row
−4.9pp
61 of 96 done, one cell each
/**
* Three data marks no chart library supplies, built in plain DOM and CSS.
*
* Two constraints shaped these rather than reaching for an SVG chart library.
*
* **Width.** A charting library typically sizes itself to a ~300px minimum
* regardless of its wrapper. Two of them in a narrow card overlap and read as
* one malformed shape. These marks live inside table cells and half-width
* tiles, well under that floor, so an SVG chart is the wrong tool.
*
* **Labels.** A fluid SVG with a fixed `viewBox` scales its `<text>` along with
* everything else, so a label authored at 11px paints at about 5px on a phone,
* and no single value is correct at both widths. Every label here is an HTML
* element with a real CSS size.
*
* Both are recorded failures, not hypotheticals.
*/
/**
* Deviation from a baseline: how far above or below a reference each value sits.
*
* The form exists because "which is biggest" and "which is behind" are different
* questions and the ranked bar can only answer the first. Scaled to the largest
* value, a segment at a quarter of the leader draws a quarter-width bar whether
* the leader is the target or an outlier. Anchor the bar at the reference
* instead and the direction itself carries the finding: everything short of the
* mark points one way, everything past it the other.
*
* Colour here is valence, which is what positive and negative hues are reserved
* for: this is a rate against a benchmark, not a category. Direction, the
* label's sign and the stated value all repeat it, so colour is never carrying
* the meaning alone.
*/
export function DivergingBar({
value,
baseline,
domain,
label,
}: {
value: number;
/** The reference the bar is anchored to. */
baseline: number;
/** Largest absolute deviation in the set, so every row shares one scale. */
domain: number;
/** Signed text of the deviation, e.g. "+5.34pp". Stated, never implied by colour. */
label: string;
}) {
const deviation = value - baseline;
const above = deviation >= 0;
// Half the track is each direction, so a full-domain deviation reaches an edge
// and no further.
const extent = domain > 0 ? Math.min(Math.abs(deviation) / domain, 1) * 50 : 0;
// A floor, so a real but tiny deviation still draws something. An empty track
// reads as "exactly on the benchmark", a different and much rarer fact.
const width = Math.max(extent, deviation === 0 ? 0 : 0.6);
return (
<div className="flex min-w-0 items-center gap-2">
<div className="relative h-2 min-w-0 flex-1 rounded-full bg-iq-panel-raised" aria-hidden="true">
<div
className="absolute inset-y-0 rounded-full"
style={{
left: above ? "50%" : `${50 - width}%`,
width: `${width}%`,
background: above ? "var(--color-iq-pos)" : "var(--color-iq-neg)",
}}
/>
{/* The benchmark itself, drawn over the bar so the anchor is never
ambiguous. */}
<div className="absolute inset-y-[-2px] left-1/2 w-px -translate-x-1/2 bg-iq-hairline-strong" />
</div>
<span className="shrink-0 text-iq-micro tabular-nums text-iq-ink-muted">{label}</span>
</div>
);
}
/**
* Part-to-whole over a countable population: one cell per thing, filled first.
*
* A percentage bar answers "roughly how much" and stops there. When the
* population is small enough to count, counting is the point: every empty cell
* is one thing someone has to go and fix. The grid makes the remaining work look
* like work rather than like a gap in a bar.
*
* Grid, not chart. `auto-fill` reflows the columns at any width with no
* measurement step and no minimum, which is exactly what an SVG chart cannot do
* in a two-column card.
*/
export function UnitGrid({
total,
filled,
label,
tone = "var(--color-iq-ink)",
}: {
total: number;
filled: number;
/** What one cell represents, stated whenever a cell is worth more than one. */
label: (perCell: number) => string;
tone?: string;
}) {
/* Past this many cells the squares fall to a pixel or two and the grid stops
being countable, which is the only reason to draw it. Beyond the cap one
cell stands for several and the caption says so, rather than silently
changing what the reader is counting. 600 rather than 400 because a
population of ~400 is a real input and a threshold that close would halve
the resolution to save four cells; at 600 a 24-column grid still gives
roughly 7px squares in a two-column card. */
const CELL_CAP = 600;
const perCell = total > CELL_CAP ? Math.ceil(total / CELL_CAP) : 1;
const cells = Math.ceil(total / perCell);
const filledCells = Math.round(filled / perCell);
return (
<div>
<div
className="grid gap-[2px]"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(7px, 1fr))" }}
aria-hidden="true"
>
{Array.from({ length: cells }, (_, i) => (
<div
key={i}
className="aspect-square rounded-[1px]"
style={{ background: i < filledCells ? tone : "var(--color-iq-panel-raised)" }}
/>
))}
</div>
<p className="mt-2 text-iq-micro text-iq-ink-muted">{label(perCell)}</p>
</div>
);
}
/**
* Two measures of the same thing on one axis, joined by the distance between
* them.
*
* When the question is "where does attention arrive and not convert", the gap is
* the finding. A ranked bar can show one measure at a time, so showing two means
* two bars and asking the reader to hold one while looking at the other. Here the
* mismatch is a length, and a segment that draws attention and earns nothing
* collapses to a single dot with nothing beside it, which is what that segment
* actually is.
*
* Both measures are shares of their own totals, so they are comparable despite
* being counted in different units. The caller states both raw figures in text.
*/
export function Dumbbell({
a,
b,
toneA = "var(--color-iq-ink-faint)",
toneB = "var(--color-iq-ink)",
}: {
/** First measure, as a 0..1 share of its own total. */
a: number;
/** Second measure, as a 0..1 share of its own total. */
b: number;
toneA?: string;
toneB?: string;
}) {
const lo = Math.min(a, b);
const hi = Math.max(a, b);
return (
// Inset by a dot radius so a share of 1 keeps its dot inside the card.
<div className="mx-1 h-2" aria-hidden="true">
<div className="relative h-full">
<div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-iq-hairline" />
<div
className="absolute top-1/2 h-px -translate-y-1/2 bg-iq-hairline-strong"
style={{ left: `${lo * 100}%`, width: `${(hi - lo) * 100}%` }}
/>
<span
className="absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ left: `${a * 100}%`, background: toneA }}
/>
<span
className="absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ left: `${b * 100}%`, background: toneB }}
/>
</div>
</div>
);
}
Playground
Sample row
+5.4pp
Sample row
+1.1pp
Sample row
−0.6pp
Sample row
−4.9pp