GODRICH

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 chart finishes drawing. 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.

01Line chart that draws itself

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

stroke-dashoffsetpathLengthpointermove
// 스케일 함수 — 데이터 값 → viewBox 좌표. 이 두 함수가 그래프의 전부다
function scaleX(i) { return PAD_L + (PAD_R - PAD_L) * i / (DATA.length - 1); }
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }

// 인접 점의 중점을 잇고 실제 점은 제어점으로 쓰는 부드러운 선 — 꺾임이 살면서 물결지지 않는다
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

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. The loop measured 14.742% cumulative area and intensity 166.4, and it suits weekly sales summary cards that highlight the week's peak.

transform-originscaleYanimation-delay
// 데이터 값 → viewBox 좌표. 막대 그래프의 핵심은 이 두 함수다
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }
function band() { return (PAD_R - PAD_L) / DATA.length; }          // 막대 하나가 차지하는 폭
function centerX(i) { return PAD_L + band() * (i + .5); }          // 막대 중심 = 라벨 위치

// SVG 자식을 만드는 헬퍼 — 속성만 채워 부모에 붙인다
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;
}
// 막대의 scaleY 는 밑변 축에 붙은 채 위로 자란다 — baseline 에 닿는 아래쪽이 원점이므로
// transform-origin bottom 이 정당하다. transform-box: fill-box 로 rect 자체를 기준 박스로
.cb__bar { transform-box: fill-box; transform-origin: 50% 100%; }

03Donut chart filling one segment at a time

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

stroke-dasharraystroke-dashoffsetanimation-delay
// 시작·끝 각도(라디안)로 원호 path 한 조각 — M 으로 붓을 대고 A 로 호를 긋는다
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;   // 180도 넘는 호에만 큰 호 플래그
  return 'M' + x0 + ',' + y0 + ' A' + R + ',' + R + ' 0 ' + large + ' 1 ' + x1 + ',' + y1;
}

04Radar chart unfolding from the center

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

pointstransform-originscale
// i번째 축의 좌표 — 위꼭짓점(i=0)에서 시작해 시계 방향 60도 간격.
// r 에 점수 비율을 곱하면 데이터 위치, 눈금 반지름을 그대로 넣으면 격자 꼭짓점이 된다
function pos(i, r) {
  var rad = (i * 60 - 90) * Math.PI / 180;
  return [CX + r * Math.cos(rad), CY + r * Math.sin(rad)];
}
// 반지름 r 육각형의 points 문자열 — 눈금 격자와 데이터 폴리곤이 같은 함수에서 나온다
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

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. The 42-cell tint sweep measured 29.057% cumulative area and 22/23 moved frames, fitting visit-time density panels and activity reports.

animation-delayanimation-fill-modefill
// 농도 함수 — 이 데모의 주제. 색은 한 가지로 고정하고 투명도만 값에 비례해 움직인다
function fillOpacity(v) { return 0.12 + 0.88 * v / 100; }
// 칸마다 --d 지연으로 좌에서 우로 순서대로 나타난다.
// 낱개 속성 + animation-fill-mode: backwards — animation 단축은 delay 를 0 으로 리셋하고,
// fill-mode 가 없으면 지연 구간에 완성 상태가 첫 프레임에 번쩍 지나간다
.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

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

pathLengthstroke-dasharrayr
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());
// 종가 점 — 상승 추세라 라인과 같은 계열색. 팝은 점 중심에서 커진다
.cs__dot { fill: $chart-1; transform-box: fill-box; transform-origin: center; }

07Stacked area chart filling from below

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. Three layers rising in turn measured 33.087% cumulative area and intensity 160.0, fitting device and browser share trend panels.

clip-pathopacitytransform-origin
// k번째 레이어 path — 위 경계를 좌에서 우로 걷고 아래 경계를 거꾸로 돌아와 면적을 닫는다
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

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. Big cells appearing in order measured 53.981% cumulative area, fitting category sales mix panels and portfolio breakdown views.

fill-opacitypointermoveviewBox
// 간이 squarified 이진 분할 — 값 내림차순 목록을 남은 영역에서 절반 무게에 가장 가까운 지점으로 나누고
// 종횡비가 1 에 가까워지는 방향(가로/세로)으로 재귀한다. 완전한 squarify 는 아니지만 정사각 비율이 잘 나온다
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

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. The loop sweeping the needle from 0 to 68 measured intensity 141.5, fitting goal attainment displays and quota progress cards.

rotatestroke-dasharraytransform-origin
// 값 → 바늘 회전각(도). 바늘 기본 모양이 12시라 0% 에서 -90(왼쪽 끝), 100% 에서 +90(오른쪽 끝)
function angleFor(v) { return v * 1.8 - 90; }
// 값 → 반원 위의 수학 각도(도). 0% 가 180(왼쪽), 100% 가 0(오른쪽) — 회전각과 다른 값이라는 점이 함정
function degreeFor(v) { return 180 - v * 1.8; }
// 수학 각도 → viewBox 좌표. SVG 는 y 가 아래로 자라므로 sin 을 **빼야** 위쪽 반원이 나온다
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%

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 do the charts 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.