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

JavaScript Chart Drawing, 9 Ways — No Library

A javascript chart is a data array turned into x·y coordinates painted onto a canvas as lines, bars, and arcs.

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

The order here isn't difficulty; it's how many drawing tools get added to the canvas, one at a time. A single stroke (01) picks up a rectangle fill and becomes a bar (02), then an arc and becomes a donut (03). Scatter forty points on that same plane and you get a scatter plot (04); stack two filled shapes and you get an area chart (05). The next four are about how that ink moves — a needle overshoots and settles on one number (06), a time window slides while five mini lines stay alive (07), a polygon morphs with a different speed per vertex (08), and finally 56 cells fill in a wave (09). If pixel math interests you more than charts, 9 canvas special-effect scenes covers water-droplet refraction and a black-hole lens; for vector drawing there's SVG chart drawing, 9 ways, and for charts you can already drag and click, see dashboard chart interactions, 9 ways.

01Line chart draw — twelve weeks revealed as a clip opens

What you're looking at

Press replay and twelve weeks of sales [128, 176, 142, 210, 248, 204, 292, 336, 288, 372, 414, 468] draw themselves left to right; a paper-colored marker lands on every vertex the pen has passed, then the end point grows from radius 2.6 to 5.5 and holds.

Line chart draw
Open in a new tab

01 · Line chart draw — Measured area 20.228% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

quadraticCurveTocliplineTo

Where the math lives

Across a 2-second loop, 23 of 24 frames moved and cumulative area came in at 20.228% — a good fit for the first row of a weekly or monthly report, driven by projecting progress p onto eleven week-to-week segments to get the current edge and end-point coordinates.

// draw progress p(0..1) -> { i, k, ex, ey, f }
//   f = 0..11 normalized elapsed, i·k = current edge, (ex,ey) = end point
function headAt(p) {
  var n = WEEKS.length;
  var f = Math.min(1, p / DRAW_AT) * (n - 1);
  var i = Math.min(n - 2, Math.floor(f));
  var k = f - i;
  var ex = xAt(i) + (xAt(i + 1) - xAt(i)) * k;
  var ey = yAt(WEEKS[i]) + (yAt(WEEKS[i + 1]) - yAt(WEEKS[i])) * k;
  return { i: i, k: k, f: f, ex: ex, ey: ey };
}
// each passed vertex becomes a control point pressed toward the midpoint
// of the next vertex via quadraticCurveTo, so the line grazes each value
function tracePath(p) {
  var d = headAt(p);
  ctx.beginPath();
  ctx.moveTo(xAt(0), yAt(WEEKS[0]));
  for (var j = 1; j < d.i; j++) {
    ctx.quadraticCurveTo(xAt(j), yAt(WEEKS[j]), (xAt(j) + xAt(j + 1)) / 2, (yAt(WEEKS[j]) + yAt(WEEKS[j + 1])) / 2);
  }
  if (d.i >= 1) {
    ctx.quadraticCurveTo(xAt(d.i), yAt(WEEKS[d.i]), d.ex, d.ey);
  } else {
    ctx.lineTo(d.ex, d.ey);
  }
  return d;
}

Using each vertex as a control point pressed toward the midpoint of the next keeps the curve grazing the real data instead of snapping through it like a staircase, while a clipping rect opens at the same rate so the line only ever shows what it has already drawn.

02Bar chart grow — a wave that spreads through stagger

What you're looking at

Five revenue bars grow from the floor toward their value, each one starting later than the last (stagger) so growth ripples like a wave across the row; once a bar lands, its value label counts up above it.

Bar chart grow
Open in a new tab

02 · Bar chart grow — Measured area 14.631% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

staggereaseOutCubicfillRect

Where the math lives

23 of 24 frames moved with cumulative area at 14.631% — this reads best in a channel or location comparison dashboard, thanks to easeOutCubic (fast start, blunt landing), which is what makes each bar feel like it thuds into place instead of just stopping.

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

// five bars — bar i starts at i*STAGGER and grows over GROW (the stagger wave)
for (var i = 0; i < n; i++) {
  var row = ROWS[i];
  var cx = PAD_L + slot * (i + 0.5);
  var fullH = plotH * (row.v / 450);

  // floor track — the landing spot is drawn faint up front so the target is visible
  ctx.fillStyle = C_TRACK;
  ctx.fillRect(cx - barW / 2, baseY - fullH, barW, fullH);

  var local = Math.max(0, Math.min(1, (p - i * STAGGER) / GROW));
  var eased = easeOutCubic(local);
  var h = fullH * eased;
  var y = baseY - h;

  if (h > 0) {
    ctx.fillStyle = row.c;
    ctx.fillRect(cx - barW / 2, y, barW, h);
  }
}

The five bars share one easeOutCubic curve, and only the stagger offset (i * STAGGER) differs between them — that single offset is what turns five identical fillRect growths into a wave instead of five bars ticking up in lockstep.

03Donut chart sweep — the next slice starts where the last one ended

What you're looking at

Three traffic sources sweep in as arcs, painted with arc; the second slice's startAngle picks up exactly where the first slice's endAngle left off, so the ring fills without a seam, and the center total flips to its final number last.

Donut chart sweep
Open in a new tab

03 · Donut chart sweep — Measured area 11.843% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcstartAngleendAngle

Where the math lives

Cumulative area lands at 11.843% over 23 of 24 moving frames — modest, since a ring only paints a stroke around its own radius, which is exactly what makes it good for traffic mix and composition at a glance.

function sweepAngle(p) {
  var q = Math.max(0, Math.min(1, p / SWEEP));
  return q;   // 0..1, 1 = full ring
}

function slice(cx, cy, R, ring, a0, a1, color, alpha) {
  if (a1 <= a0) return;
  ctx.beginPath();
  ctx.arc(cx, cy, R - ring / 2, a0, a1);
  ctx.strokeStyle = color;
  ctx.lineWidth = ring;
  ctx.lineCap = 'butt';
  ctx.globalAlpha = alpha;
  ctx.stroke();
  ctx.globalAlpha = 1;
}

Chaining each slice's a0 to the previous slice's a1 is the whole trick — it's the only reason three separate arc calls read as one continuous ring instead of three stacked strokes.

04Scatter arrival — forty points converging through lerp

What you're looking at

Forty products start at random positions and settle onto the price-review plane through repeated lerps; hover catches the nearest point and shows the product name.

Scatter arrival
Open in a new tab

04 · Scatter arrival — Measured area 19.836% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

lerpMath.expdevicePixelRatio

Where the math lives

21 of 24 frames moved with cumulative area at 19.836% — well ahead of most items here because forty moving dots cover a lot of the canvas even before they land, which is why this is the item to reach for when exploring price-satisfaction correlation.

// data value -> plotted coordinate. x is price, y is review count (up = more)
function px(v) { return PAD_L + (W - PAD_L - PAD_R) * (v / X_SPAN); }
function py(v) { return PAD_T + (H - PAD_T - PAD_B) * (1 - v / Y_SPAN); }

// closed-form lerp convergence — deterministically places a point at
// elapsed time e(seconds), writes cx·cy, returns progress t
function converge(pt, e) {
  var t = 1 - Math.exp(-pt.lam * e);
  var x0 = PAD_L + (W - PAD_L - PAD_R) * pt.sx;
  var y0 = PAD_T + (H - PAD_T - PAD_B) * pt.sy;
  pt.cx = x0 + (px(pt.price) - x0) * t;
  pt.cy = y0 + (py(pt.rv) - y0) * t;
  return t;
}

converge never accumulates a running velocity — it recomputes position straight from elapsed time e with Math.exp, so scrubbing, pausing, or resizing mid-flight can never leave a point stuck between its start and target.

05Area chart rise — two series that brighten where they cross

What you're looking at

Two series fill upward through a rising clip rect, and in any month where the leading series changes, the series now in front brightens slightly; once the fill lands, an average line is ruled straight across both.

Area chart rise
Open in a new tab

05 · Area chart rise — Measured area 25.02% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

globalAlphaclipquadraticCurveTo

Where the math lives

23 of 24 frames moved and cumulative area hit 25.02% — the second-highest of the nine, since two stacked fills cover more pixels than a single line or ring, which suits actual-versus-target reporting where the cumulative trend matters more than any single point.

// fill progress — one easing curve drives both the clip and the fill height together
var f = easeOutCubic(Math.min(1, p / FILL_AT));
var s = f * f;                                   // fill height — fast start, settles at the end

// clipping rect — opens upward from the floor. only what's inside it is painted
var cutY = (H - PAD_B) - (H - PAD_T - PAD_B) * f;

ctx.save();
ctx.beginPath();
ctx.rect(0, cutY - 1, W, H);
ctx.clip();

ctx.globalAlpha = 0.4;
// ... fill path drawn here, then the crossing series switches globalAlpha
ctx.globalAlpha = 1;

Squaring the eased value (s = f * f) for the fill height while leaving the clip rect on the linear f is what keeps the paint edge and the visible fill in sync without a second easing curve — one easeOutCubic call drives the whole rise.

06Gauge needle swing — overshoot and spring damping

What you're looking at

The half-dial needle swings past its target angle and returns through spring damping before settling (an overshoot); only the segment of the scale up to the current value is colored, and the number locks in at the final angle.

Gauge needle swing
Open in a new tab

06 · Gauge needle swing — Measured area 10.783% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.cosarctextAlign

Where the math lives

21 of 24 frames moved with cumulative area at 10.783%, the second-lowest of the nine, since a needle and a thin arc occupy less canvas than a filled shape — a fair tradeoff for a KPI attainment or service-status tile where one number is the entire point.

function angOf(v) { return Math.PI + Math.PI * (v / 100); }

// value -> point at radius r (polar to cartesian). angle increases clockwise
// (from the left, over the top, to the right)
function pt(v, r) {
  var a = angOf(v);
  return { x: cx() + Math.cos(a) * r, y: cy() + Math.sin(a) * r };
}

// sweep progress u(0..1) -> needle value (0..100+), a damped cosine wave
// that overshoots the target and settles back: VALUE * (1 - e^(-DECAY*u) * cos(OMEGA*u))
function needleVal(u) {
  return VALUE * (1 - Math.exp(-DECAY * u) * Math.cos(OMEGA * u));
}

Math.cos paired with an exponential decay is what produces the overshoot — a plain easeOutCubic needle would glide straight to the target with no swing, which is a duller signal for "attainment just landed."

07Sparkline stream — five cards living on a sliding window

What you're looking at

Five card mini-lines shift their time window three times over a two-second loop and redraw; the newest point pulses and the value updates the moment it lands.

Sparkline stream
Open in a new tab

07 · Sparkline stream — Measured area 11.706% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

easeOutCubicrequestAnimationFramepath

Where the math lives

23 of 24 frames moved and cumulative area sits at 11.706% — small, because a sparkline is deliberately a thin line inside a card rather than a filled region, which is exactly the shape you want for mini metrics buried inside list rows.

function swapState(i, p) {
  var shift = 0;
  if (p >= SWAP_AT[1]) shift = 2;
  else if (p >= SWAP_AT[0]) shift = 1;
  if (shift === 0) return { shift: shift, slide: 0 };
  var lastAt = SWAP_AT[shift - 1] + i * STAG;
  var raw = Math.min(1, Math.max(0, (p - lastAt) / SWAP_DUR));
  return { shift: shift, slide: easeOutCubic(raw) };
}

// entrance — cards appear left to right with a stagger (first 20% of the loop)
for (var i = 0; i < CARDS.length; i++) {
  var appear = easeOutCubic(Math.min(1, Math.max(0, (p - i * 0.04) / 0.16)));
  drawCard(i, rects[i], p, appear);
}

swapState is what makes the window feel like it's streaming rather than resetting — each card slides its oldest point out with the same easeOutCubic curve used for the entrance, staggered per card by i * STAG inside a requestAnimationFrame loop so all five don't shift on the same frame.

08Radar morph — a different clock per vertex

What you're looking at

The six-axis polygon morphs from team A's shape to team B's, but each vertex eases on its own clock; two translucent fills overlap so the shared region between the shapes reads darker.

Radar morph
Open in a new tab

08 · Radar morph — Measured area 7.3% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

polygoneaseOutCubicglobalAlpha

Where the math lives

23 of 24 frames moved with cumulative area at 7.3% — the lowest of all nine, since a six-point polygon outline covers far less canvas than any bar, area, or donut, yet it still earns its place for side-by-side skill comparisons where shape, not area, is what a viewer reads.

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

// unit direction for axis i — clockwise starting at 12 o'clock
function angleAt(i) { return -Math.PI / 2 + (Math.PI * 2 * i) / N; }

// morph progress for vertex i (0..1). p is overall loop progress, span is
// this segment's total length (normalized). starts VTX[i].off late and
// finishes VTX[i].rate times faster/slower — every vertex drifts apart.
function vertexP(i, p, span, start) {
  var local = (p - start - VTX[i].off) / (span / VTX[i].rate);
  return Math.max(0, Math.min(1, local));
}

Giving every vertex its own off and rate inside vertexP, instead of one shared easeOutCubic progress for the whole polygon, is what makes six points morph at visibly different speeds rather than snapping through the same shape in unison; globalAlpha on the two overlapping fills is what turns their intersection into a third, darker region for free.

09Heatmap wave — 56 cells filling on a diagonal

What you're looking at

Seven days by eight hours fill in one cell at a time in a wave from the top-left, with each value mapped through a scale into five pastel steps; hovering a cell reads out its day and value.

Heatmap wave
Open in a new tab

09 · Heatmap wave — Measured area 34.969% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

scalergbapointermove

Where the math lives

21 of 24 frames moved and cumulative area is the highest of the nine at 34.969% — 56 cells filling in sequence simply paint more of the canvas than a single line or arc ever does, which is exactly the point for scanning a visit-time or usage pattern at a glance.

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

// value (0..100) -> a 5-step color-ramp index (0..4). a 20-point scale
function rampAt(v) {
  return Math.min(4, Math.floor(v / 20));
}

// fill progress for cell (i,j) (0..1) — diagonal wave: (i+j)*coefficient
// plus a sine phase so no two cells start at exactly the same instant
function cellAt(i, j, p) {
  var phase = Math.sin((i * 3.7 + j * 2.9)) * WAVE;
  var start = (i + j) * DIAG + phase;
  return Math.max(0, Math.min(1, (p - start) / GROW));
}

cellAt keys the start time off (i + j), the diagonal distance from the top-left cell, which is the entire reason 56 independent cells read as one wave instead of 56 cells blinking on in random order; the sine phase only nudges neighbors apart so no two cells land on the exact same frame.

Where should a javascript chart example start — data, scale, or easing?

Nail the axis scale before anything else

An html grid and one script file is all you need to start, but there are two numbers to settle before any coordinate math: the y-axis floor and range. In this set, 01 runs 100–500, 05 runs 50–500, and 04 pairs y 0–500 with x 0–20 (in thousands) — each chosen by hand. Leave the axis at the data's raw min/max and the margins shrink but the tick labels land on awkward numbers; round the range to a clean number and the picture feels stable instead. Bar chart (02) locks its scale to four fixed 100-unit ticks so the axis never jumps around when the underlying data changes.

The easing function decides what the picture says

Swap the easing on identical data and you get a different chart. Five of these nine (02·05·07·08·09) share one decelerating curve, 1 - (1-t)^3, because a curve that's fast at first and slides to a stop matches the personality of a chart about "values accumulating and settling." 06's needle instead runs on a damped cosine wave, and 01's end point uses easeOutBack — each pulled its own curve on purpose. Anyone building a javascript graph library from scratch will find that where easing goes isn't polish, it's the difference between a chart that reads as calm and one that reads as urgent.

Match devicePixelRatio to the physical pixel grid

All nine re-run fit() whenever the window resizes, and inside it cv.width = Math.round(W * dpr) is followed by ctx.setTransform(dpr, 0, 0, dpr, 0, 0) to align the coordinate system. Skip that one line and axis labels and value numbers turn soft on any display denser than 1x — canvas code should always be written in logical pixels, with the physical resolution touched exactly once, at setup.

Where it breaks — pitfalls

The mistake we hit most often is that canvas's y-axis opens downward. Plot a value straight onto y and the line goes down as sales go up — which is why 01's yAt flips it with 1 - (v - floor) / range, and why 06 counts its upper half-circle from PI (left) through 1.5PI (top) to 2PI (right) for the same reason. The same flip governs rotation, too — canvas angles increase clockwise, not counter-clockwise as on a standard unit circle, which is why 03 starts its sweep at -90 degrees and lets the angle grow to keep the slices landing in order. The second recurring trap is the track — the faint background a chart draws to show where a shape will land. Painting 03's full track on the very first frame leaves that area static for the entire animation, so half the canvas never actually moves; switching to opening the track only as far as the pen has already traveled is what brought cumulative area back to life.

What breaks How it shows up How it was handled
Plotting the raw value onto y The line goes down as values go up Flip with 1-(v-floor)/range
No dpr set Labels and numbers blur on 2x displays setTransform(dpr,0,0,dpr,0,0)
Full track painted on frame one That area never visibly changes Track opens only as far as drawn (03)
roundRect unsupported in the browser An error leaves the whole canvas blank typeof check falls back to rect (04)
Value labels over a saturated chip color Contrast falls below the target Placed on the plain stage background instead (09)

The password to open the file that holds all nine as one download is 6ypv8dkk, and once it's unzipped every item has its own browser-ready HTML page and stylesheet.

Accessibility

A canvas is a picture, so it's an empty box to a screen reader — every one of the nine attaches role="img" with an aria-label describing what's drawn (MDN aria-label), plus a visually-hidden sr-only table beside it carrying the same data as text (04 shows 8 representative rows out of 40 since the full set would be long, and 09 summarizes hours in pairs). Following the WCAG 1.4.1 rule that color alone must never carry meaning, 03's donut legend spells out each name and percentage, and 09's heatmap states its average and peak cell in words.

Under prefers-reduced-motion: reduce, all nine drop their loop and render a single finished frame instead — 01 shows all twelve weeks drawn with the end point already enlarged, 02 shows fully-grown bars with the count-up finished, 03 shows a complete ring with the total already reading 24,000. 04 and 09 still redraw on mousemove even at rest, so hover highlights and tooltips keep working for anyone who's turned motion down. 06 settles with its needle locked at 72 (progress 0.95), and 08 holds the two overlapping polygons at team B's shape (progress 0.75).

FAQ

Can this be used like a graph drawing site you just open and try?

Yes. Each item's HTML is a single self-contained page that runs the moment you open the file, with no values to type in first — the demo window on this page embeds that exact file, so what you see here and what you get from the download draw the same picture.

What's the difference from using a javascript graph library?

A library gets you axes, legends, and tooltips in one call, at the cost of drawing everything its own way. These nine items have zero external dependencies, so copying one file drops it into any page, and every detail — how many gridlines (02 fixes four at 100-unit steps), how wide the gap is between donut slices (03 uses 0.035 radians), how many times the needle swings (06 uses damping 5.2 at angular frequency 10.5) — is something you set directly in the code. Once a dashboard grows past a couple of charts and needs synced tooltips across them, a library earns its place; for one or two charts you want to actually own, this route is shorter.

What do I change to plug in real data?

Swap the constant arrays near the top of each file — WEEKS in 01, ROWS in 02, the point list in 04 — for your own numbers, then re-check the axis floor and range described above, since a wider real dataset will usually need a new Y_SPAN or X_SPAN rather than the demo's rounded defaults. The easing calls and clip rects don't need to change at all — they run identically over a different array. Anyone searching for a canvas chart js starting point can use these files as-is: rename the input array, keep the render loop.

Enter the archive password

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