Data-viz
Seven primitives that need no charting library. They are inline SVG and plain elements painted with kit tokens, so a theme switch needs no redraw — unlike canvas, which is what the Chart.js adapter is for.
Two ways to use every primitive
Each one mounts from data-* attributes on load, and each one has a matching call on
_78.viz for values that arrive later. The attributes are the authoring surface; the
JavaScript is for live data. (The figure row is the exception, and the
cheapest of the lot: it is pure CSS, with nothing to mount.)
// Attributes — mounted automatically on load
// <div data-values="4,7,6,9,12"></div>
// …or drive it yourself, any time
_78.viz.sparkline(el, [4, 7, 6, 9, 12], { fill: false, tone: 'success' });
_78.viz.progress(el, 72, { label: 'Storage used' });
_78.viz.bar(el, 82);
_78.viz.donut(el, 68, { size: 120, value: '68 of 100' });
_78.viz.trend(el, -2.8, { unit: 'pts' });
_78.viz.splitBar(el, [{ pct: 35, tone: 'danger', label: 'Risk' },
{ pct: 65, tone: 'success', label: 'Reward' }]);
// Mount everything inside a container you just inserted
_78.viz.mountAll(myNewPanel); // scope optional; defaults to document
Calling a primitive again on the same element re-renders it in place, so updating a live number is
just another call. Every signature is fn(el, value, options), and the value may be
omitted when the element carries the matching attribute.
Tones
Color flows through two inherited custom properties, --viz and --viz-lo,
which default to the accent. A tone class sets both, so it can go on the primitive or on any
ancestor — put it on a card and everything inside follows.
| Class | Color |
|---|---|
_78-tone-accent | --accent — the default, for making it explicit |
_78-tone-success | --success |
_78-tone-warn | --warn |
_78-tone-danger | --danger |
_78-tone-info | --info |
_78-tone-dim | --dim — for a de-emphasized series |
The same six names as every other component — see Tones. The aliases
(_78-tone-green, -red, -amber, -yellow) work too, and so do
data-tone and the tone option in JS.
Sparkline
Any element with data-values becomes one — the kit renders an
<svg class="_78-sparkline"> inside it. Values are comma or space separated. A
series needs at least two points.
| Attribute | Option | What it does |
|---|---|---|
data-values | data arg | The series. Comma or space separated. |
data-tone="auto" | tone | auto colors by direction — success if the series ends higher, danger if lower. Or name a tone. |
data-fill="false" | fill | Drops the tinted area under the line, leaving the line alone. On by default. |
data-dot="true" | dot | Draws the marker on the last point. Off by default — see below. |
data-label | label | Overrides the generated aria-label. |
| — | width · height | viewBox size, default 120×32. The SVG stretches to its container regardless. |
Fill and the end dot
The tinted area under the line is on by default; data-fill="false" drops it, which is
what you want when a stack of sparklines would otherwise be a wall of color. The dot on the last
point is off by default and opt-in with data-dot="true" — it
crowds a small card, and a sparkline is a shape, not a series of readable points.
data-dot="true" (or
dot: true) to get it back.
<div class="site-demo-block">
<div>
<div class="_78-eyebrow _78-mb-8">Default — fill, no dot</div>
<div class="_78-sparkline" data-values="18,22,19,27,25,31,29,38,44,41,52" data-tone="auto"></div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">data-dot="true"</div>
<div class="_78-sparkline" data-values="18,22,19,27,25,31,29,38,44,41,52" data-tone="auto" data-dot="true"></div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">data-fill="false"</div>
<div class="_78-sparkline" data-values="18,22,19,27,25,31,29,38,44,41,52" data-tone="auto" data-fill="false"></div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">data-tone="auto" on a falling series</div>
<div class="_78-sparkline" data-values="40,38,35,36,31,28,24,21,19" data-tone="auto"></div>
</div>
</div>
The SVG stretches to its container with preserveAspectRatio="none", and neither the line
nor the dot notices: both are non-scaling strokes. The dot is a zero-length path drawn by its round
cap, so it stays a 5px circle at any width, with a --bg2 halo under it.
<circle>, which stretched into a flat ellipse in any
container wider than about 120px. It is round now. If you restyled it, its color is now
stroke rather than fill, and its size is stroke-width.
Accessible label
The SVG is role="img" with a generated label — “Trend, 11 points: 18 to 52
(up)”. Replace it with data-label when you can say something more useful.
<div data-values="18,22,19,27,52" data-label="Revenue, January to May, up 189%"></div>
Parts
| Class | What it is |
|---|---|
_78-sparkline | The generated <svg>. Full width, 32px tall. |
_78-sparkline-line | The path. Non-scaling stroke, so it never fattens. |
_78-sparkline-area | The tinted fill under the line. |
_78-sparkline-dot | The last-point marker. Only drawn when you ask for it with dot: true. A round-capped stroke: color it with stroke, size it with stroke-width. |
_78-sparkline-dot-ring | The --bg2 halo under the dot, 3px wider than it. |
_78-sparkline-base | Styling for a dashed zero baseline. The kit styles it but does not draw it — it is a hook for a <line> you add to the generated SVG yourself. |
Progress
_78-progress with data-pct. The track is generated and given a real
role="progressbar" with aria-valuenow, so the value is never carried by
width alone.
<div class="site-demo-block">
<div class="_78-progress" data-pct="72">
<div class="_78-progress-head"><span class="_78-progress-label">Storage used</span><span class="_78-progress-value"></span></div>
</div>
<div class="_78-progress _78-tone-success _78-progress-lg" data-pct="88">
<div class="_78-progress-head"><span class="_78-progress-label">Uptime</span><span class="_78-progress-value"></span></div>
</div>
<div class="_78-progress _78-tone-danger _78-progress-sm" data-pct="23">
<div class="_78-progress-head"><span class="_78-progress-label">Budget remaining</span><span class="_78-progress-value"></span></div>
</div>
</div>
| Attribute / class | Option | What it does |
|---|---|---|
data-pct | pct arg | 0–100, clamped. |
data-label | label | The aria-label. Falls back to the text of _78-progress-label. |
| — | text: false | Leaves _78-progress-value empty instead of filling in the percentage. |
_78-progress-lg | — | 10px track instead of 6px. |
_78-progress-sm | — | 4px track. |
Leave _78-progress-value empty and the percentage is written into it. Put your own text
there and it is left alone — which is how you show “18 of 25” instead of 72%.
_78.viz.progress(el, 72, { label: 'Storage used', text: false });
Bar rows
A ranked list: label, track, value. Wrap them in _78-bar-list for the spacing. Unlike
progress, the track must be in your markup — the row is a three-column grid, so the kit does
not invent the cells.
<div class="_78-bar-list">
<div class="_78-bar-row" data-pct="100">
<span class="_78-bar-label">Direct</span>
<span class="_78-bar-track"><span class="_78-bar-fill"></span></span>
<span class="_78-bar-value">4,120</span>
</div>
<div class="_78-bar-row" data-pct="76">
<span class="_78-bar-label">Search</span>
<span class="_78-bar-track"><span class="_78-bar-fill"></span></span>
<span class="_78-bar-value">3,140</span>
</div>
<div class="_78-bar-row _78-tone-info" data-pct="52">
<span class="_78-bar-label">Referral</span>
<span class="_78-bar-track"><span class="_78-bar-fill"></span></span>
<span class="_78-bar-value">2,150</span>
</div>
<div class="_78-bar-row _78-tone-dim" data-pct="14">
<span class="_78-bar-label">Email</span>
<span class="_78-bar-track"><span class="_78-bar-fill"></span></span>
<span class="_78-bar-value">580</span>
</div>
</div>
data-pct is the bar's share of the track, not the displayed number — the value on
the right is yours. The aria-label falls back to the text of
_78-bar-label, or pass { label }.
_78.viz.bar(el, 82, { label: 'Direct traffic' });
Donut and gauge
_78-donut with data-pct draws a ring with the value in the middle. Add
_78-gauge and the same ring is drawn as a 240° arc with the gap at the bottom.
<div class="_78-donut" data-pct="68" data-label="Capacity"></div>
<div class="_78-donut _78-tone-success" data-pct="88" data-label="Uptime"></div>
<div class="_78-donut _78-donut-sm _78-tone-danger" data-pct="23" data-label="Budget"></div>
<div class="_78-donut _78-donut-lg _78-tone-info" data-pct="54" data-label="Coverage"></div>
<div class="_78-donut _78-gauge _78-tone-warn _78-donut-lg" data-pct="41" data-label="Risk"></div>
| Attribute / class | Option | What it does |
|---|---|---|
data-pct | pct arg | 0–100. Fills the ring and, by default, is written in the middle. |
data-label | label | The caption under the value, and the accessible name. |
| — | value | Arbitrary center text instead of the percentage — “18/25”, “A+”, a currency total. |
| — | size | viewBox size, default 100. |
| — | stroke | Ring thickness. Default 12, or 11 for a gauge. |
_78-gauge | gauge | Draw as a 240° arc. The option overrides the class. |
_78-donut-sm / -lg | — | 72px / 136px wide instead of the default 100px. |
value is the one that is easy to miss: the ring can show a proportion while the middle
shows whatever number actually means something to the reader.
_78.viz.donut(el, 72, {
value: '18/25', // center text, instead of "72%"
label: 'Seats used',
size: 120,
stroke: 14
});
_78.viz.donut(el, 41, { gauge: true, label: 'Risk score' });
Put your own text inside _78-donut-value in the markup and it is left alone, the same
way the progress value is.
Trend
_78-trend with data-delta renders an arrow, the number and a unit. The
direction comes from the sign — flat at exactly zero. _78-stat-delta is the same
primitive styled for a stat card.
<span class="_78-trend" data-delta="12.4"></span>
<span class="_78-trend" data-delta="-2.8"></span>
<span class="_78-trend" data-delta="0"></span>
<span class="_78-trend" data-delta="1.2" data-unit="pts"></span>
<span class="_78-trend" data-delta="340" data-unit=""></span>
<span class="_78-trend _78-trend-pill" data-delta="4.6"></span>
<span class="_78-trend _78-trend-pill" data-delta="3.1" data-invert="true"></span>
| Attribute / class | Option | What it does |
|---|---|---|
data-delta | value arg | The signed change. Its sign picks the arrow and the color. |
data-unit | unit | Suffix, default %. Set it to an empty string for a bare number. |
data-invert="true" | invert | Swaps the colors for metrics where up is bad. Never flips the arrow. |
| — | label | The screen-reader word after the number. Defaults to “up” / “down” / “no change”. |
| — | text | Replaces the rendered text entirely. |
_78-trend-pill | — | Tinted pill background instead of bare text. |
Markup-only
If you already have the text, skip the JavaScript entirely: set the direction class yourself and the styling still applies.
<span class="_78-trend _78-up">▲ 12.4%</span>
<span class="_78-trend _78-down">▼ 2.8%</span>
<span class="_78-trend _78-flat">— 0%</span>
_78.viz.trend(el, -2.8, { unit: 'pts', label: 'down on last week' });
Figure row
_78-figure-row is N related numbers read side by side, each with a label and a
tone shown as a colored left edge: entry / take-profit / stop, plan / actual / variance, min /
median / max, three-tier pricing. One fact in parts — where a
_78-kpi-grid is unrelated
metrics and a stat card is one headline number.
It is CSS only — no attributes, no JavaScript — and it takes the same
_78-tone-* classes as everything else on this page, so a figure
and the chart beside it agree on color.
<div class="_78-figure-row">
<div class="_78-figure _78-tone-accent">
<span class="_78-figure-label">Plan</span>
<span class="_78-figure-value">$42,000</span>
</div>
<div class="_78-figure _78-tone-success">
<span class="_78-figure-label">Actual</span>
<span class="_78-figure-value">$48,290</span>
<span class="_78-figure-sub">+$6,290</span>
</div>
<div class="_78-figure _78-tone-danger">
<span class="_78-figure-label">Cost of sale</span>
<span class="_78-figure-value">$11,400</span>
<span class="_78-figure-sub">27% of actual</span>
</div>
</div>
Variants
_78-figure-strong gives the value the tone color as well as the edge;
_78-figure-row-sm is the denser version for a table cell or a narrow card; and
_78-figure-row-plain drops the fill and the edge and moves the tone to the label.
_78-figure-strong works on a single figure or on the whole row.
<div class="site-demo-block">
<div>
<div class="_78-eyebrow _78-mb-8">_78-figure-row-sm _78-figure-strong</div>
<div class="_78-figure-row _78-figure-row-sm _78-figure-strong">
<div class="_78-figure _78-tone-dim">
<span class="_78-figure-label">Low</span>
<span class="_78-figure-value">1.0790</span>
</div>
<div class="_78-figure _78-tone-accent">
<span class="_78-figure-label">Median</span>
<span class="_78-figure-value">1.0842</span>
</div>
<div class="_78-figure _78-tone-success">
<span class="_78-figure-label">High</span>
<span class="_78-figure-value">1.0985</span>
</div>
</div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">_78-figure-row-plain</div>
<div class="_78-figure-row _78-figure-row-plain">
<div class="_78-figure _78-tone-accent">
<span class="_78-figure-label">Starter</span>
<span class="_78-figure-value">$19</span>
<span class="_78-figure-sub">per seat, monthly</span>
</div>
<div class="_78-figure _78-tone-info">
<span class="_78-figure-label">Team</span>
<span class="_78-figure-value">$49</span>
<span class="_78-figure-sub">per seat, monthly</span>
</div>
<div class="_78-figure _78-tone-dim">
<span class="_78-figure-label">Enterprise</span>
<span class="_78-figure-value">Talk to us</span>
<span class="_78-figure-sub">annual only</span>
</div>
</div>
</div>
</div>
Neutral figures
Not every number in a row carries meaning. "Time left" beside a target and a stop is just a fact, and on
a colored edge it reads as one more signal. _78-figure-neutral drops the tone from one
figure — even a tone it would inherit from the row or a parent — so its edge is the plain
--border. _78-figure-row-neutral does it for the whole row: no semantic edges
at all, and any _78-tone-* inside is ignored.
<div class="site-demo-block">
<div class="_78-figure-row">
<div class="_78-figure _78-tone-success">
<span class="_78-figure-label">Signal when</span>
<span class="_78-figure-value">2.4%</span>
<span class="_78-figure-sub">below the trigger</span>
</div>
<div class="_78-figure _78-tone-danger">
<span class="_78-figure-label">Dead if</span>
<span class="_78-figure-value">$41.20</span>
<span class="_78-figure-sub">closes under</span>
</div>
<div class="_78-figure _78-figure-neutral">
<span class="_78-figure-label">Time left</span>
<span class="_78-figure-value">6</span>
<span class="_78-figure-sub">trading days</span>
</div>
</div>
<div class="_78-figure-row _78-figure-row-neutral">
<div class="_78-figure">
<span class="_78-figure-label">Opened</span>
<span class="_78-figure-value">Sep 14</span>
</div>
<div class="_78-figure">
<span class="_78-figure-label">Held</span>
<span class="_78-figure-value">22 days</span>
</div>
<div class="_78-figure">
<span class="_78-figure-label">Size</span>
<span class="_78-figure-value">140 shares</span>
</div>
</div>
</div>
| Class | What it is |
|---|---|
_78-figure-row | The row: an auto-fit grid, minimum 140px per figure, a --space-3 gap. It wraps rather than squashing. |
_78-figure | One figure: a --bg3 fill, --radius-sm, and a 3px --viz left edge. |
_78-figure-label | The uppercase --fs-xs label. Always say what the number is. |
_78-figure-value | The number: --fs-lg, bold, tabular figures. |
_78-figure-sub | A small dim line under the value. |
_78-figure-strong | On a figure or the row: the value takes the tone color too. |
_78-figure-row-sm | Denser padding and an --fs-base value. |
_78-figure-row-plain | No fill and no edge; the tone moves to the label. |
_78-figure-neutral | On one figure: no tone, even an inherited one. The edge is --border, a -strong value is --text, a -plain label is --dim. |
_78-figure-row-neutral | On the row: every figure neutral — no semantic edges at all. |
Split bar
One whole divided into named, proportional segments: risk versus reward, spent versus remaining,
pass / skip / fail. _78-progress is a single fill of one track
and _78-bar-row is one row of a ranked list; this is the split of
a whole into parts.
Segments carry data-pct and data-label, and
_78.viz.splitBar() sizes them on load like every other primitive here. The head and the
legend above and below it are optional, and are plain markup you write.
<div class="site-demo-block">
<div class="_78-split-head">
<span class="_78-split-title">Risk / reward</span>
<span class="_78-split-total">1 : 1.9</span>
</div>
<div class="_78-split-bar" data-label="Risk versus reward">
<span class="_78-split-seg _78-tone-danger" data-pct="35" data-label="Risk"></span>
<span class="_78-split-seg _78-tone-success" data-pct="65" data-label="Reward"></span>
</div>
<div class="_78-split-legend">
<span class="_78-split-key _78-tone-danger">Risk <b>35%</b></span>
<span class="_78-split-key _78-tone-success">Reward <b>65%</b></span>
</div>
</div>
How the numbers are shared out
Two behaviors do the arithmetic you would otherwise do by hand, and they are worth knowing because they decide what your numbers mean.
- Under 100, the shortfall stays as visible track. A single segment at 68 renders as 68% used and 32% empty — so “68% of budget” needs one segment, not two.
-
Over 100, the values are treated as raw amounts and shared out.
512 / 96 / 32renders as 80% / 15% / 5%. You pass the amounts you actually have; the percentages are the kit's problem.
<div class="site-demo-block">
<div>
<div class="_78-eyebrow _78-mb-8">One segment at 68 — the rest stays track</div>
<div class="_78-split-bar" data-label="Budget used">
<span class="_78-split-seg _78-tone-warn" data-pct="68" data-label="Used"></span>
</div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">Raw amounts: 512 / 96 / 32 — shared out to 80 / 15 / 5</div>
<div class="_78-split-bar _78-split-bar-lg" data-label="Storage by type">
<span class="_78-split-seg _78-tone-accent" data-pct="512" data-label="Media"></span>
<span class="_78-split-seg _78-tone-info" data-pct="96" data-label="Documents"></span>
<span class="_78-split-seg _78-tone-dim" data-pct="32" data-label="Other"></span>
</div>
<div class="_78-split-legend">
<span class="_78-split-key _78-tone-accent">Media <b>512 GB</b></span>
<span class="_78-split-key _78-tone-info">Documents <b>96 GB</b></span>
<span class="_78-split-key _78-tone-dim">Other <b>32 GB</b></span>
</div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">_78-split-bar-sm</div>
<div class="_78-split-bar _78-split-bar-sm" data-label="Test run">
<span class="_78-split-seg _78-tone-success" data-pct="184" data-label="Passed"></span>
<span class="_78-split-seg _78-tone-warn" data-pct="12" data-label="Skipped"></span>
<span class="_78-split-seg _78-tone-danger" data-pct="4" data-label="Failed"></span>
</div>
</div>
</div>
In JavaScript
Pass an array to build the segments, or call it with no array to re-size markup that is already there — which is what the automatic mount does.
_78.viz.splitBar(el, [
{ pct: 45, tone: 'accent', label: 'Equities' },
{ pct: 30, tone: 'info', label: 'FX' },
{ pct: 25, tone: 'dim', label: 'Cash' }
], { label: 'Allocation' });
_78.viz.splitBar(el); // omit the array to size the markup already in the page
| Class / attribute | What it is |
|---|---|
_78-split-bar | The track: 8px tall, pill radius, filled with --border, with a 2px gap between segments. |
_78-split-seg | One segment, painted from --viz. Its width is set by _78.viz.splitBar(). |
_78-split-bar-sm / -lg | 4px / 12px tall instead of 8px. |
_78-split-head | Optional title-and-total line above the bar. |
_78-split-title / _78-split-total | Its two halves — a dim label and a solid tabular value. |
_78-split-legend | A wrapping row of keys under the bar. |
_78-split-key | One key. Give it the segment's tone class and it gets a matching dot. |
data-pct | On a segment: its share, or its raw amount. See above. |
data-label | On a segment, its name; on the bar, the name of the whole. |
role="img" whose aria-label lists every segment and
its share — “Risk versus reward: Risk 35%, Reward 65%” — and each segment
repeats its own text as a title. The visible legend is still yours to write: the
wording is your product's, and it is what makes the colors mean anything on screen.