GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Chart Design: 9 SVG Graphs, No Library

CSS chart design turns numbers into lines, bars, and areas with no library. These nine use only SVG coordinate math and CSS animation — paste the files and the

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by how each line chart css drawing finishes. First the strokes — a path joined from x/y math (01), bars growing from a baseline (02), and arc segments filling in turn (03). Next the fills — a polygon unfolding from the center (04), grid cells tinting by depth (05), and stacked layers rising from the bottom (07). The last three divide space or point at a value — a short trend inside a card (06), a treemap split by area (08), and a gauge that turns one number into an angle (09). Stage colors rotate three ink, three paper, two yellow, and one orange, so the nine grid cells never tilt toward one brightness. If you want to interact with finished charts instead of drawing them, see 9 dashboard chart interactions, and for counting a single value with one ring, see 9 circular stat ring widgets. Push the same coordinate math into a place where a new value arrives every second and stock ticker UI takes over the refresh.

01Line chart that draws itself

What you see

Twelve months of visits go through two scale functions into one path, and pathLength=100 plus a stroke-dashoffset sweep from 100 to 0 draws it left to right like a pen. Each vertex joins the midpoint of its neighbors while the data point itself becomes the control point, so the line keeps its bends without rippling.

Line chart that draws itself
Open in a new tab

01 · Line chart that draws itself — Measured area 25.276% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetpathLengthpointermove

Where to look in the code

The pen-stroke loop measured 25.276% cumulative area and 18/23 moved frames, and it belongs in monthly traffic report summaries and the first card of an admin dashboard.

// scale functions — data value → viewBox coordinate. these two functions are the whole graph
function scaleX(i) { return PAD_L + (PAD_R - PAD_L) * i / (DATA.length - 1); }
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }

// a smooth line joining midpoints of adjacent points, with the real points as control points — the corners survive without the line rippling
function buildPath() {
  var pts = DATA.map(function (v, i) { return [scaleX(i), scaleY(v)]; });
  var d = 'M' + pts[0][0] + ',' + pts[0][1];
  for (var i = 1; i < pts.length - 1; i++) {
    var mx = (pts[i][0] + pts[i + 1][0]) / 2, my = (pts[i][1] + pts[i + 1][1]) / 2;
    d += ' Q' + pts[i][0] + ',' + pts[i][1] + ' ' + mx + ',' + my;
  }
  var last = pts[pts.length - 1];
  d += ' L' + last[0] + ',' + last[1];
  return d;
}

02Column bars growing from the baseline

Where graph design css starts the bar

Weekly sales render as one cell per bar, raised in sequence by --d delays, and transform: scaleY grows each bar from the baseline axis, with transform-origin: 50% 100% — the bottom edge touching the baseline must be the origin, or the bar stretches from its middle instead of growing. Value labels swap between half and final values with a cut, and only the peak bar (Saturday) takes the series blue.

Column bars growing from the baseline
Open in a new tab

02 · Column bars growing from the baseline — Measured area 14.688% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform-originscaleYanimation-delay

How to read the code

The loop measured 14.742% cumulative area and intensity 166.4, and it suits weekly sales summary cards that highlight the week's peak.

// data value → viewBox coordinate. these two functions are the heart of the bar chart
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }
function band() { return (PAD_R - PAD_L) / DATA.length; }          // width one bar occupies
function centerX(i) { return PAD_L + band() * (i + .5); }          // bar center = label position

// helper that makes an SVG child — fills in the attributes and appends it to the parent
function el(name, attrs, parent) {
  var n = document.createElementNS(SVGNS, name);
  for (var k in attrs) { n.setAttribute(k, attrs[k]); }
  parent.appendChild(n);
  return n;
}
// the bar's scaleY grows upward while staying pinned to the baseline axis — the bottom edge touching
// the baseline is the origin, which is what makes transform-origin bottom right. transform-box: fill-box makes the rect itself the reference box
.cb__bar { transform-box: fill-box; transform-origin: 50% 100%; }

03Donut chart filling one segment at a time

What moves

Traffic shares take their angles clockwise from 12 o'clock as four arc A commands, and each segment carries pathLength=100, so a stroke-dashoffset sweep from 100 to 0 draws it regardless of arc length. The 2px gap between segments comes from shortening the paths by a gap angle, letting the cream background act as the divider.

Donut chart filling one segment at a time
Open in a new tab

03 · Donut chart filling one segment at a time — Measured area 7.771% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dasharraystroke-dashoffsetanimation-delay

What to change

Back-to-back brush strokes over the 2-second loop measured 7.767% cumulative area and intensity 169.0, fitting traffic source cards that show composition at a glance.

// one arc path segment from start and end angles (radians) — M sets the brush down, A draws the arc
function arcPath(a0, a1) {
  var x0 = (CX + R * Math.cos(a0)).toFixed(2), y0 = (CY + R * Math.sin(a0)).toFixed(2);
  var x1 = (CX + R * Math.cos(a1)).toFixed(2), y1 = (CY + R * Math.sin(a1)).toFixed(2);
  var large = (a1 - a0) > Math.PI ? 1 : 0;   // large-arc flag only for arcs over 180 degrees
  return 'M' + x0 + ',' + y0 + ' A' + R + ',' + R + ' 0 ' + large + ' 1 ' + x1 + ',' + y1;
}

04Radar chart unfolding from the center

What you see

Six axis scores land on pos(i, r) trigonometric coordinates, so the tick hexagon rings and the data polygon come out of the same function. The polygon unfolds with transform: scale, and because an SVG element's CSS transform reference box defaults to the viewBox origin, transform-box: fill-box with transform-origin: center is what makes it grow from its own middle.

Radar chart unfolding from the center
Open in a new tab

04 · Radar chart unfolding from the center — Measured area 5.192% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointstransform-originscale

Where to look in the code

Polygon scaling followed by six vertex dots popping in order measured 5.192% cumulative area and 19/23 moved frames, fitting survey comparisons and feature satisfaction cards.

// coordinate of the i-th axis — starts at the top vertex (i=0) and steps 60 degrees clockwise.
// multiply r by the score ratio for a data position, or pass a ring radius straight in for a grid vertex
function pos(i, r) {
  var rad = (i * 60 - 90) * Math.PI / 180;
  return [CX + r * Math.cos(rad), CY + r * Math.sin(rad)];
}
// points string for a hexagon of radius r — the ring grid and the data polygon come out of the same function
function ringPts(r) {
  var s = '';
  for (var i = 0; i < N; i++) { var p = pos(i, r); s += (i ? ' ' : '') + p[0].toFixed(1) + ',' + p[1].toFixed(1); }
  return s;
}

05Heatmap cells filling in sequence

What happens on screen

Forty-two day-by-hour density cells keep one fixed color while only fill-opacity moves, driven by the depth function 0.12 + 0.88 × v / 100. Each cell carries an animation-delay stepped by 28ms so the tint sweeps left to right, row by row — and delayed loops need animation-fill-mode: backwards, or the finished state flashes in during the delay.

Heatmap cells filling in sequence
Open in a new tab

05 · Heatmap cells filling in sequence — Measured area 29.057% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-delayanimation-fill-modefill

How to read the code

The 42-cell tint sweep measured 29.057% cumulative area and 22/23 moved frames, fitting visit-time density panels and activity reports.

// depth function — the point of this demo. the color stays fixed and only the opacity moves with the value
function fillOpacity(v) { return 0.12 + 0.88 * v / 100; }
// each cell appears in order from left to right on its own --d delay.
// longhand properties + animation-fill-mode: backwards — the animation shorthand resets delay to 0,
// and without fill-mode the finished state flashes past in the first frame of the delay
.ch.is-demo .ch__cell {
  animation-name: chCell;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: var(--d);
  animation-fill-mode: backwards;
}

06Sparkline living inside a KPI card

What moves

A 14-day trend shrinks to a short path that fits the card width, and the final dot marks the period's closing value in the same series color. Two copies of the big number (half and final) share one grid cell and swap with a steps(1, end) cut, so the figure changes without shaking the layout.

Sparkline living inside a KPI card
Open in a new tab

06 · Sparkline living inside a KPI card — Measured area 3.914% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

pathLengthstroke-dasharrayr

What to change

The loop chaining line drawing, dot pop, and number cut measured intensity 128.8, fitting mini trends inside KPI cards and secondary metrics in list rows.

function buildPath() {
  return DATA.map(function (v, i) {
    return (i ? 'L' : 'M') + scaleX(i).toFixed(1) + ',' + scaleY(v).toFixed(1);
  }).join(' ');
}
line.setAttribute('d', buildPath());
// closing-price dot — an upward trend, so it takes the same series color as the line. the pop grows from the dot's center
.cs__dot { fill: $chart-1; transform-box: fill-box; transform-origin: center; }

07Stacked area chart filling from below

What you see

Three share series sum to 100 every month, producing four stacked boundaries (floor, top of desktop, top of mobile, ceiling), and each layer path walks its upper boundary left to right, then walks the lower boundary backward to close the area. The preview overlays a clip-path: inset wipe on the same paths, filling upward from the bottom layer.

Stacked area chart filling from below
Open in a new tab

07 · Stacked area chart filling from below — Measured area 33.087% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

clip-pathopacitytransform-origin

Where to look in the code

Three layers rising in turn measured 33.087% cumulative area and intensity 160.0, fitting device and browser share trend panels.

// path for the k-th layer — walks the upper boundary left to right, then comes back along the lower boundary to close the area
function layerPath(k) {
  var d = '';
  for (var i = 0; i < MON.length; i++) d += (i ? ' L' : 'M') + xAt(i).toFixed(1) + ',' + yAt(B[k + 1][i]).toFixed(1);
  for (var j = MON.length - 1; j >= 0; j--) d += ' L' + xAt(j).toFixed(1) + ',' + yAt(B[k][j]).toFixed(1);
  return d + ' Z';
}

08Treemap seating the biggest first

What happens on screen

Category sales are sorted by value, then a simplified squarify splits the remaining rectangle at the point closest to half the total weight and recurses in whichever direction approaches square aspect ratios. Labels are drawn only on cells with enough area; smaller cells hand their values to the hover tooltip and the screen reader table.

Treemap seating the biggest first
Open in a new tab

08 · Treemap seating the biggest first — Measured area 53.984% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

fill-opacitypointermoveviewBox

How to read the code

Big cells appearing in order measured 53.981% cumulative area, fitting category sales mix panels and portfolio breakdown views.

// simplified squarified binary split — takes the value-descending list, cuts the remaining rectangle at the point closest to half the weight,
// and recurses in whichever direction (horizontal/vertical) brings the aspect ratio nearer to 1. not a full squarify, but the cells come out close to square
function split(items, x, y, w, h, out) {
  if (!items.length) { return; }
  if (items.length === 1) { out.push({ item: items[0], x: x, y: y, w: w, h: h }); return; }
  var total = 0;
  for (var k = 0; k < items.length; k++) { total += items[k].v; }
  var half = total / 2, acc = 0, i = 0;
  while (i < items.length - 1 && Math.abs(acc + items[i].v - half) < Math.abs(acc - half)) { acc += items[i].v; i++; }
  var ratio = acc / total;
  if (w >= h) {
    split(items.slice(0, i), x, y, w * ratio, h, out);
    split(items.slice(i), x + w * ratio, y, w * (1 - ratio), h, out);
  } else {
    split(items.slice(0, i), x, y, w, h * ratio, out);
    split(items.slice(i), x, y + h * ratio, w, h * (1 - ratio), out);
  }
}

09Half-circle gauge with a live needle

What moves

Type a goal attainment value and it becomes the stroke-dashoffset (100 minus the percent) that fills the half-circle arc, while the needle turns to that exact angle with rotate. An SVG element's CSS transform-origin is measured in viewBox coordinates, so the gauge center (220px 126px) is written out directly.

Half-circle gauge with a live needle
Open in a new tab

09 · Half-circle gauge with a live needle — Measured area 7.21% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatestroke-dasharraytransform-origin

What to change

The loop sweeping the needle from 0 to 68 measured intensity 141.5, fitting goal attainment displays and quota progress cards.

// value → needle rotation (degrees). the needle's base shape points at 12 o'clock, so 0% is -90 (far left) and 100% is +90 (far right)
function angleFor(v) { return v * 1.8 - 90; }
// value → mathematical angle on the semicircle (degrees). 0% is 180 (left) and 100% is 0 (right) — the trap is that this is not the same number as the rotation
function degreeFor(v) { return 180 - v * 1.8; }
// mathematical angle → viewBox coordinate. SVG grows y downward, so you must **subtract** sin to get the upper semicircle
function pointAt(deg, radius) {
  var rad = deg * Math.PI / 180;
  return [CX + radius * Math.cos(rad), CY - radius * Math.sin(rad)];
}

Where it breaks — the trap

The first trap is mixing two angle conventions in one formula. SVG's y axis grows downward, so computing coordinates from a math angle means subtracting the sine — this episode's chart 09 initially used CY + radius * Math.sin(rad) with CSS-rotation instincts, and the whole half-circle flipped below the baseline. At the 60–80% section's mid-angle of 54 degrees with radius 88, the wrong y lands at 197.2, which is 21.2 past the 176-unit viewBox height, dropping the hover tooltip anchor below the baseline; subtracting the sine brought it back to 54.8 (evidence: run/289/_수리전실측.json). Note that the needle's CSS rotate turns clockwise while math angles run counterclockwise — two different numbers for the same position. The second trap is absolutely positioning a tooltip above the card — 07's single-row three-series tooltip overflowed the page by 37px at phone width, and only moving it inside the plot and stacking it vertically keeps it within 320px. The third is the rewind frame stealing the poster — if the teardown at the loop's end changes more pixels than the entrance, the poster becomes an empty card, so hold the finished state to 96% and push the reset to 98%, outside the sampled frames.

Trap Symptom Fix
Sine sign written with CSS instincts Half-circle flips below y grows downward, so CY - r*sin(rad)
Tooltip absolutely placed above the card Overflows 37px at phone width Inside the plot, stacked vertically
Rewinding at the loop's end Poster becomes an empty card Hold to 96%, reset at 98%

Letting a reader edit the drawn path on screen hands the job to the editing parts in code editor JavaScript. The archive password for the nine graphs is s6hc9dw3 — unpacking the archive gives every item in two matched editions: a vanilla file you open straight in the browser and a React component you paste into your app.

Accessibility (reduced-motion)

A chart that speaks only in color is unreadable, so all nine carry role="img" with a value-summarizing aria-label, and a screen reader data table sits fixed off-canvas so the full numbers can be heard. Ticks, legends, and tooltips restate values as text for the same reason. Under prefers-reduced-motion the preview loops stop and jump to the fully drawn state — the gauge (09) freezes with its needle at the 68% angle.

FAQ

Why draw with SVG instead of Chart.js?

Because the build output stays a static file with no library dependency. SVG coordinate math lives in a single file you open straight in a browser, and even the animations are pure CSS. Automatic axis scaling and tooltip alignment become code you write yourself, but as these nine show, that amounts to little more than two scale functions (MDN SVG path attribute).

Is stroke-dashoffset animation heavy?

Not for a handful of paths. This episode's loops were measured by rendering 24 frames at 12fps: the busiest chart (08, the treemap) changes at most 23.879% of the canvas between consecutive frames, and the heatmap's 42 cell delays spread evenly across 22/23 frames. Mixing in properties that force layout — top, width — is what gets expensive, so keep chart motion to stroke and transform.

How does a line chart html css layout resize responsively?

One viewBox does it. Coordinates are computed in viewBox units (440 wide on the full-width charts), and a single CSS rule — width: 100%; height: auto — scales the whole drawing with its container. Only label collisions need a media query, shrinking fonts or skipping alternate months; all nine passed the 320px check with zero overflow.

Enter the archive password

The password is inside this article. You will find it as you read.