- Design system
- SVG & data
09 — SVG & data graphics
SVG & data graphics
Icons, charts, and the self-contained data cards on github.com/tanghoong. An SVG is a surface like any other — same tokens, same radius, same easing curve.
scripts/generate-cards.mjs and committed as static SVG — no third-party badge
service in the render path. Every number on this page was read back out of those files.
They already use this system's tokens exactly: rx="20" is --r-lg,
the card stroke is --hairline, gridlines are --hairline-soft, and
the motion is cubic-bezier(.22,1,.36,1) at 260/420 ms.
Icons
Stroke icons on a 24 px grid, rendered at 16–20 px. Always currentColor — an icon that hard-codes a hex breaks the moment it lands on a filled button or in the other theme.
The same six on a filled accent — they inherit --accent-ink with no extra rule:
<svg width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M5 12h14m-6-7 7 7-7 7"/>
</svg>
Decorative icons are aria-hidden="true" and the control beside them carries the aria-label. An icon that is the content gets role="img" and a <title> instead.
| Rendered at | stroke-width | Use |
|---|---|---|
| 12–14px | 2.4 | Chevrons and inline marks — thin strokes disappear at this size |
| 16px | 2 | Inside buttons and links |
| 18–20px | 1.8 | Icon buttons, toolbars, list markers |
| 24px+ | 1.5 | Empty states, feature marks |
The card system
A card is a complete SVG — frame, type, data and animation in one file, with no external CSS. It has to stand alone because GitHub strips <style> from Markdown but honours it inside an SVG.
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="208"
viewBox="0 0 880 208" fill="none" role="img"
preserveAspectRatio="xMidYMid meet">
<!-- The 0.5 offset keeps a 1px stroke crisp: a whole-pixel edge
straddles two device pixels and renders as 2px of grey. -->
<rect x="0.5" y="0.5" width="879" height="207" rx="20"
fill="#ffffff" stroke="#000000" stroke-opacity="0.1"/>
<!-- dark twin: fill="#1c1c1e" stroke="#ffffff" stroke-opacity="0.13" -->
</svg>
Every card is 880 wide so a column of them lines up. rx="20" is --r-lg. The stroke opacities are --hairline in each theme — 10% black, 13% white.
| Card | Height | Idiom |
|---|---|---|
| streak | 168 | Three-column stat row |
| overview | 208 | Stats plus a stacked share bar |
| rhythm | 208 | Two panels side by side |
| activity | 268 | Weekly columns on a gridded plot |
| languages | 276 | Full-width share bar and legend |
| ability | 336 | Six-axis radar |
| frameworks | 356 | Proportional blocks |
| calendar | 428 | Polar year clock |
Type inside an SVG
SVG has no rem, so sizes are raw px and do not scale with the reader's font setting. That is the single biggest constraint here: keep card text at 8.5 px or above, and never put anything essential in it that is not also in the aria-label.
| size | weight | tracking | Role |
|---|---|---|---|
| 30 | 700 | -0.9 | Headline figure in a stat column |
| 20 | 700 | -0.6 | Secondary figure |
| 14 | 600 | -0.2 | Card title |
| 12 | 600 | -0.07 | Panel heading, series name |
| 11 | 600 | -0.07 | Label inside a filled block |
| 10 | 600 | +0.7 | Uppercase stat label — positive tracking, it is set in caps |
| 10 | 400 | -0.06 | Card subtitle, date range, footnote |
| 9 | 600 | -0.05 | Axis caption |
| 9 | 400 | -0.06 | Axis value, secondary label in a block |
| 8.5 | 600 | -0.05 | Month label on the year clock — the floor |
style="font-variant-numeric:tabular-nums" on every figure. Proportional digits make a column of numbers ripple.
The chart ramp
Not a fixed palette — a function. Interpolate from --c-from to --c-to and sample it at however many series you have. The overview card samples 6, frameworks 10, languages 16, all from the same two endpoints.
6 — the --c-1 … --c-6 tokens
10 — node scripts/ramp.mjs 10
16 — node scripts/ramp.mjs 16
# The generator lives in the repo and reads the endpoints from tokens.css, # so it cannot drift from the system. node scripts/ramp.mjs # the 6 steps in tokens.css node scripts/ramp.mjs 10 # any sample count node scripts/ramp.mjs 16 --json # for a card generator to consume # Endpoints, from tokens.css: # --c-from light-dark(#03744e, #4dff9b) the accent itself # --c-to light-dark(#7ee6c3, #107f40) # 🔴 The interpolation happens in OKLab, not sRGB. See scripts/ramp.mjs — # the conversion is about 30 lines and it is the whole reason the steps # come out evenly spaced.
The direction inverts by theme: light runs dark → pale, dark runs bright → deep. In both, index 0 is the largest series and carries the most weight against its own background. --c-1 is the accent itself, so a one-series chart and a primary button are the same green.
Idiom — stat row
Three figures across the card, divided by vertical hairlines at the thirds. The figure is the headline, the uppercase label says what it counts, and the third line gives it a denominator.
Columns are centred at 880/6, 880/2 and 880×5/6 — 146.67, 440, 733.33. Dividers sit at the thirds, insetting 68 px from the top and 22 from the bottom so they never touch the card edge.
Idiom — share bar
One rounded track, segments in ramp order, labels beneath. The right form for parts of a whole when the parts are named.
- TypeScript 42%
- Go 24%
- Python 16%
- CSS 9%
- Shell 5%
- Other 4%
<!-- The track is a rounded rect; segments are plain paths drawn over it.
Clipping the segments to the radius is unnecessary and costs a
clipPath — the track's own corners show through at both ends. -->
<rect x="0" y="0" width="600" height="10" rx="5" fill="#f5f5f7"/>
<path d="M0 0h252v10H0z" fill="#03744e"/>
<path d="M252 0h144v10H252z" fill="#268a64"/>
Track height 8–10 px, rx exactly half the height. The whole graphic is one role="img" with the figures spelled out — a screen reader should never have to infer a number from a bar width.
Idiom — proportional blocks
When every series needs a name and a number visible at once, drop the legend and label each block in place. Width is proportional; height is constant. This is what the frameworks card does, and it is why that card can carry ten series without confusion.
Blocks are rx="10" with a 4 px gutter, labels inset 9 px. Watch the label contrast as the ramp lightens — white holds to about the fourth step, then the label has to flip to a dark ink. That switch is the one thing this idiom will get wrong if it is automated carelessly.
Idiom — columns
Weekly activity as columns on a gridded plot. Gridlines behind, axis values in the left margin, columns growing from the baseline.
<!-- Columns animate with scaleY from the baseline. transform-origin must
be explicit and in user units — an SVG element's default origin is the
viewport corner, not the element, so without it every bar grows from
the top-left of the whole card. -->
<rect x="34" y="140" width="18" height="28" class="bar"
style="transform-origin:43px 168px; animation-delay:0.10s"/>
@keyframes bar { from { transform: scaleY(0); } }
The plot insets from the card edge to leave a left margin for axis values — 54 px in on the generated card, against 24 for everything else. Gridlines are --chart-grid and sit behind the data, never over it.
Idiom — radar
Six axes, three gridline rings, an accent polygon filled at 16%. More than six axes and the shape stops being readable; fewer than five and a bar chart says it better.
The fill is 0.16 rather than the --accent-wash 12% — a wash sits over a flat surface, this sits over gridlines and needs a little more body. The polygon animates with grow-radar, scaling from the centroid, so transform-origin is the radar's centre in user units.
Idiom — year clock
The most decorative card, and last in the column for that reason. Twelve months around a ring, each day a spoke whose length and colour carry the count. Use it when the shape of a year is the point — not when anyone needs to read a specific value off it.
// Each day is a spoke between an inner and outer radius. Angle is the day's
// position in the year; length and colour both carry the count, so the
// pattern survives being printed in greyscale.
const R_IN = 52, R_OUT = 124, cx = 150, cy = 150;
days.forEach((count, i) => {
const a = (i / days.length) * Math.PI * 2 - Math.PI / 2; // 12 o'clock
const len = R_IN + (R_OUT - R_IN) * (count / max);
spoke(cx + Math.cos(a) * R_IN, cy + Math.sin(a) * R_IN,
cx + Math.cos(a) * len, cy + Math.sin(a) * len,
ramp(steps, theme)[bucket(count)]);
});
Inner radius 52, outer 124 — the hole matters. Spokes converging on a point turn the centre into an unreadable smear, and the ring keeps the eye on the part that carries data.
Idiom — comparison track
This year against last, on one pill. The solid fill is now; the dashed outline is the figure it is being measured against. One track, not two rows — the comparison is the point.
Track rx is exactly half the height (26 → 13), so it is a true pill. The dashed comparison is the accent at 45% with stroke-dasharray="3 3" — present, but clearly the reference rather than the subject.
Motion
Five animations, one curve, and a stagger. All of it is the page's own motion tokens: 260 ms is --dur, 420 ms is --dur-slow, and the curve is --ease. A card animating on a different curve from the page it sits on is the kind of mismatch nobody names but everybody feels.
<style>
/* 🔴 no-preference, NOT a `reduce` override. An SVG carries its own styles,
so the global rule in base.css cannot reach inside it — the guard has to
be repeated here or the card ignores the setting entirely. */
@media (prefers-reduced-motion: no-preference) {
.fade { animation: fade 260ms cubic-bezier(.22, 1, .36, 1) both; }
.rise { animation: rise 420ms cubic-bezier(.22, 1, .36, 1) both; }
.grow { animation: grow 420ms cubic-bezier(.22, 1, .36, 1) both; }
.bar { animation: bar 420ms cubic-bezier(.22, 1, .36, 1) both; }
.draw { animation: draw 900ms cubic-bezier(.22, 1, .36, 1) both; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } }
@keyframes rise { from { opacity: 0; transform: translateY(12px); } }
@keyframes grow { from { transform: scaleX(0); } }
@keyframes bar { from { transform: scaleY(0); } }
@keyframes draw { from { stroke-dashoffset: 1; } }
}
</style>
| Class | Duration | From | Use |
|---|---|---|---|
.fade | 260ms | opacity 0, +6px | Type, labels, gridlines — by far the most used |
.rise | 420ms | opacity 0, +12px | Headline figures and whole blocks |
.grow | 420ms | scaleX(0) | Horizontal bars and share segments |
.bar | 420ms | scaleY(0) | Vertical columns, growing from the baseline |
.draw | 900ms | stroke-dashoffset | A trend line drawing itself on. One per card at most |
transform-origin in user units. The default origin is the SVG viewport corner, so every bar will grow from the top-left of the whole card.
Theming an SVG
Two routes, and which one you need depends on whether the SVG is inline or loaded as a file.
var(--token) and currentColor directly. The SVG is part of the document, so it inherits the theme with no extra work — every chart on this page does this. The global reduced-motion rule reaches it too.
<img src="…svg"> cannot see the page's custom properties, and its animation is outside the document. Ship a light/dark pair, switch with <picture>, and repeat the motion guard inside each file.
<picture>
<source srcset="./assets/overview-dark.svg" media="(prefers-color-scheme: dark)">
<img src="./assets/overview-light.svg" width="100%"
alt="GitHub stats: 5,945 contributions, 42% TypeScript, 24% Go…">
</picture>
The alt has to carry the actual figures. A reader who cannot see the card gets nothing from "GitHub stats card", and the figures are most of the value.
node scripts/ramp.mjs <n> reads --c-from and
--c-to from tokens.css and interpolates in OKLab. Do not
interpolate in sRGB: it looks like the obvious choice and it produces perceptually
uneven steps that bunch at one end. The first version of this ramp did exactly that,
and three of its six steps were indistinguishable.
SVG checklist
- Decorative icon:
aria-hidden="true", and the control carries the label - Meaningful graphic:
role="img"plus anaria-labelstating the actual figures - Icons use
currentColor— never a hard-coded hex - Stroke width matches the rendered size, not the 24 px viewBox
- Figures carry
font-variant-numeric: tabular-nums - Uppercase labels use positive tracking; everything else negative
- No text below 8.5 px, and nothing essential lives only in the text
- Label ink flips from white to dark as the ramp lightens
- Gridlines sit behind the data and use
--chart-grid transform-originset explicitly, in user units, on anything that scales- Animated SVG files repeat their own
prefers-reduced-motionguard - 1 px strokes sit on a
.5offset so they stay crisp - A file-loaded SVG has a light and a dark twin behind
<picture> - Checked in both themes — a chart that only works on white is half done