tanghoong design
  1. Design system
  2. 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.

Everything here is measured, not invented The eight cards in the profile README are generated daily by 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 icon contract

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 atstroke-widthUse
12–14px2.4Chevrons and inline marks — thin strokes disappear at this size
16px2Inside buttons and links
18–20px1.8Icon buttons, toolbars, list markers
24px+1.5Empty 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.

Frame and grid
Card title Supporting line — what the numbers are measuring 24 padding 24 padding plot area starts y=84 title baseline y=36 subtitle baseline y=54
<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.

Canonical heights, as generated. Width is always 880.
CardHeightIdiom
streak168Three-column stat row
overview208Stats plus a stacked share bar
rhythm208Two panels side by side
activity268Weekly columns on a gridded plot
languages276Full-width share bar and legend
ability336Six-axis radar
frameworks356Proportional blocks
calendar428Polar year clock
Order the column most-signal-first Identity and headline numbers, then "is this person active right now", then "what do they build with", then the slower-moving shape of the year. The tallest and most decorative card goes last, where its height costs the reader nothing.

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.

Every size/weight/tracking combination used across the eight cards
sizeweighttrackingRole
30700-0.9Headline figure in a stat column
20700-0.6Secondary figure
14600-0.2Card title
12600-0.07Panel heading, series name
11600-0.07Label inside a filled block
10600+0.7Uppercase stat label — positive tracking, it is set in caps
10400-0.06Card subtitle, date range, footnote
9600-0.05Axis caption
9400-0.06Axis value, secondary label in a block
8.5600-0.05Month label on the year clock — the floor
Do Set uppercase labels with positive tracking (+0.7). Caps need opening up; the negative tracking used everywhere else is for mixed-case type.
Do Put 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.

The ramp at 6, 10 and 16 samples

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.

Six is the ceiling for a legend The generated cards go past six because they label every block in place — you never match a colour back to a legend. If your chart needs a separate legend, stop at six and group the tail as "Other". Past that, a table is the honest form.

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.

Stat row — the streak card
Contribution Streak 5,945 TOTAL CONTRIBUTIONS Jun 24, 2013 – Present 82 CURRENT STREAK Jun 16 – Sep 5, 2026 82 LONGEST STREAK Jun 16 – Sep 5, 2026

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.

Stacked share bar with legend
  • 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.

Proportional blocks, labelled in place
Astro 24 repos Workers 22 repos React 21 repos Node 14 repos Vite 11 repos Other 8 repos

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.

Column plot with gridlines
6040 200
<!-- 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.

Six-axis radar
DELIVERYARCH AIDATA FRONTENDOPS

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.

Polar year clock
JANFEBMAR APRMAYJUN JULAUGSEP OCTNOVDEC
// 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.

Comparison track
2026 1,284 2025 962 Up 33% year on year, with 118 days still to run

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.

The whole style block, as generated
<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>
ClassDurationFromUse
.fade260msopacity 0, +6pxType, labels, gridlines — by far the most used
.rise420msopacity 0, +12pxHeadline figures and whole blocks
.grow420msscaleX(0)Horizontal bars and share segments
.bar420msscaleY(0)Vertical columns, growing from the baseline
.draw900msstroke-dashoffsetA trend line drawing itself on. One per card at most
Do Stagger in 0.04–0.06 s steps within a group, and about 0.12 s between groups. Past roughly 0.6 s total the reader is waiting rather than watching.
Don't Animate a transform without setting 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.

Inline in the page Use 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.
Loaded as a file An <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.
The picture pattern
<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.

The ramp is generated, not chosen 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 an aria-label stating 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-origin set explicitly, in user units, on anything that scales
  • Animated SVG files repeat their own prefers-reduced-motion guard
  • 1 px strokes sit on a .5 offset 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