78 UI Kit
Components

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.)

_78.viz
// 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.

ClassColor
_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.

AttributeOptionWhat it does
data-valuesdata argThe series. Comma or space separated.
data-tone="auto"toneauto colors by direction — success if the series ends higher, danger if lower. Or name a tone.
data-fill="false"fillDrops the tinted area under the line, leaving the line alone. On by default.
data-dot="true"dotDraws the marker on the last point. Off by default — see below.
data-labellabelOverrides the generated aria-label.
—width · heightviewBox 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.

Changed in 0.2.0
The end dot used to be on by default. If you are coming from 0.1.x and a sparkline has quietly lost its marker, that is this — add data-dot="true" (or dot: true) to get it back.
Default — fill, no dot
data-dot="true"
data-fill="false"
data-tone="auto" on a falling series
sparkline options
<div class="site-demo-block">
  <div>
    <div class="_78-eyebrow _78-mb-8">Default &mdash; 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.

Changed in 0.3.0
The end dot used to be a <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.

data-label
<div data-values="18,22,19,27,52" data-label="Revenue, January to May, up 189%"></div>

Parts

ClassWhat it is
_78-sparklineThe generated <svg>. Full width, 32px tall.
_78-sparkline-lineThe path. Non-scaling stroke, so it never fattens.
_78-sparkline-areaThe tinted fill under the line.
_78-sparkline-dotThe 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-ringThe --bg2 halo under the dot, 3px wider than it.
_78-sparkline-baseStyling 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.

Storage used
Uptime
Budget remaining
progress bars
<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 / classOptionWhat it does
data-pctpct arg0–100, clamped.
data-labellabelThe aria-label. Falls back to the text of _78-progress-label.
—text: falseLeaves _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%.

progress in JS
_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.

Direct 4,120
Search 3,140
Referral 2,150
Email 580
bar rows
<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 }.

bar in JS
_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.

donuts and gauges
<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 / classOptionWhat it does
data-pctpct arg0–100. Fills the ring and, by default, is written in the middle.
data-labellabelThe caption under the value, and the accessible name.
—valueArbitrary center text instead of the percentage — “18/25”, “A+”, a currency total.
—sizeviewBox size, default 100.
—strokeRing thickness. Default 12, or 11 for a gauge.
_78-gaugegaugeDraw 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.

donut in JS
_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.

trends
<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 / classOptionWhat it does
data-deltavalue argThe signed change. Its sign picks the arrow and the color.
data-unitunitSuffix, default %. Set it to an empty string for a bare number.
data-invert="true"invertSwaps the colors for metrics where up is bad. Never flips the arrow.
—labelThe screen-reader word after the number. Defaults to “up” / “down” / “no change”.
—textReplaces the rendered text entirely.
_78-trend-pill—Tinted pill background instead of bare text.
Invert flips the color, not the arrow
A rising error rate is still rising. Turning the arrow upside down would be a lie about the data; coloring it red is the honest way to say “this is bad”.

Markup-only

If you already have the text, skip the JavaScript entirely: set the direction class yourself and the styling still applies.

markup-only trend
<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>
trend in JS
_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.

Plan $42,000
Actual $48,290 +$6,290
Cost of sale $11,400 27% of actual
_78-figure-row
<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.

_78-figure-row-sm _78-figure-strong
Low 1.0790
Median 1.0842
High 1.0985
_78-figure-row-plain
Starter $19 per seat, monthly
Team $49 per seat, monthly
Enterprise Talk to us annual only
figure row variants
<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.

Signal when 2.4% below the trigger
Dead if $41.20 closes under
Time left 6 trading days
Opened Sep 14
Held 22 days
Size 140 shares
neutral figures
<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>
ClassWhat it is
_78-figure-rowThe row: an auto-fit grid, minimum 140px per figure, a --space-3 gap. It wraps rather than squashing.
_78-figureOne figure: a --bg3 fill, --radius-sm, and a 3px --viz left edge.
_78-figure-labelThe uppercase --fs-xs label. Always say what the number is.
_78-figure-valueThe number: --fs-lg, bold, tabular figures.
_78-figure-subA small dim line under the value.
_78-figure-strongOn a figure or the row: the value takes the tone color too.
_78-figure-row-smDenser padding and an --fs-base value.
_78-figure-row-plainNo fill and no edge; the tone moves to the label.
_78-figure-neutralOn 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-neutralOn the row: every figure neutral — no semantic edges at all.
The label is the meaning; the color is reinforcement
Never ship a figure row whose parts differ only by tone — that is color as the only signal, which is the one thing the kit's data-viz is built not to do.

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.

Risk / reward 1 : 1.9
Risk 35% Reward 65%
_78-split-bar
<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 / 32 renders as 80% / 15% / 5%. You pass the amounts you actually have; the percentages are the kit's problem.
One segment at 68 — the rest stays track
Raw amounts: 512 / 96 / 32 — shared out to 80 / 15 / 5
Media 512 GB Documents 96 GB Other 32 GB
_78-split-bar-sm
split bar totals
<div class="site-demo-block">
  <div>
    <div class="_78-eyebrow _78-mb-8">One segment at 68 &mdash; 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 &mdash; 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
_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 / attributeWhat it is
_78-split-barThe track: 8px tall, pill radius, filled with --border, with a 2px gap between segments.
_78-split-segOne segment, painted from --viz. Its width is set by _78.viz.splitBar().
_78-split-bar-sm / -lg4px / 12px tall instead of 8px.
_78-split-headOptional title-and-total line above the bar.
_78-split-title / _78-split-totalIts two halves — a dim label and a solid tabular value.
_78-split-legendA wrapping row of keys under the bar.
_78-split-keyOne key. Give it the segment's tone class and it gets a matching dot.
data-pctOn a segment: its share, or its raw amount. See above.
data-labelOn a segment, its name; on the bar, the name of the whole.
The bar names itself, but write the legend
The bar becomes one 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.