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

Pie Chart CSS: 9 Slices That Pop, Tilt and Morph

A pie chart earns its keep after drawing — 02 springs the hovered slice out along its center line, 03 tilts the disc and paints its side back to front, and 06

Everything that moves is drawn on a <canvas>, so the stylesheet holds the 480:300 stage, the buttons and the focus outline while JavaScript works out every angle. No chart library, CDN or WebGL is loaded, and the numbers inside each chart are seeded sample values rather than real statistics.

Nine Pie Charts Drawn From Seeded Sample Data, Ready to Poke

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

Hover, click or tab into any card and use the arrow keys; every card picks its slice with the same angle test explained further down.

Pie Chart CSS: 9 Charts — Sweep, Pop-Out, 3D Tilt, Data Morph, Label Push, Bar Unroll, Sunburst, Donut Hole and Light Grain

The order follows a chart's working life. 01 is the first draw, 02 and 03 answer a pointer resting on a slice, 04 and 05 deal with new numbers and crowded labels, 06, 07 and 08 turn the circle into another shape, and 09 ends by lighting the disc pixel by pixel. Each scene paints into one 2D canvas, and there is no @keyframes rule anywhere; apart from a background-color transition on the buttons, motion comes from requestAnimationFrame advancing in fixed 16ms steps.

Seeded sample values instead of real statistics

The values come from makeRng(seed), a small generator that scrambles 32-bit integers, and the slice names are placeholders running from Item A to Item I. With the default seed 467, the seven values in 01 are 12, 40, 10, 24, 38, 12 and 34, adding up to 170, and pressing New data raises the seed by one for a fresh set. None of the nine index.html files calls Math.random, Date.now, fetch or localStorage, so each scene is a plain pie chart HTML CSS JavaScript page whose shares and timings repeat exactly under the same seed.

Twenty-four captures in two seconds at 480×300

Each scene was captured 24 times across 2 seconds and every capture was compared with the one before it, which leaves 23 comparisons as the denominator of the last column.

Scene Cumulative area Mean intensity Moving frames
01 Staggered sweep 22.6% 122.5 19/23
02 Spring pop-out 25.6% 84.0 23/23
03 3D tilt with side wall 19.7% 85.1 23/23
04 Period morph 23.7% 84.4 20/23
05 Label push 5.3% 90.6 18/23
06 Pie to bars 26.3% 156.5 19/23
07 Sunburst drill 26.0% 79.8 18/23
08 Donut hole and count 22.9% 103.2 22/23
09 Pointer light and grain 22.0% 26.6 23/23

05 is the only scene below 10% cumulative area, at 5.3%, because the circle stays put while only the outside labels and their leader lines slide up and down. 01 starts sweeping at 200ms, finishes at 1,580ms and then holds until its next round, and 05 and 07 also go quiet once their push or expansion is done, so those three stop at 19, 18 and 18 frames. 09 shifts the brightness of broad areas only a little as the light travels, which keeps its mean intensity at 26.6 despite 22.0% coverage.

Matching a scene to a report, a deck or a dashboard

A report opener or a survey result card that shows the shares once and moves on needs nothing more than the sweep in 01, while a dashboard where people point at slices to check them starts from the pop-out in 02. A deck cover that wants depth takes 03, and a card whose mix changes by month or quarter takes 04. A report with so many categories that names collide uses 05, a card that flips between shares and sizes uses 06, and a breakdown with subcategories under each category uses 07. 08 belongs on a KPI card with the total in the middle, and 09 suits a landing page where the chart also serves as decoration.

How Does a List of Numbers Become Start and End Angles?

Every wedge angle comes from one ratio: value ÷ total × 2π. The layout function below keeps a running sum, divides it by the total and multiplies by 2π, giving each slice a start angle a0, an end angle a1 and a share. One slice's a1 is the next slice's a0, so there is no room for a gap or an overlap, and the last slice closes at exactly 2π because the running sum equals the total at that moment. The zero angle TOP is −π/2, which is 12 o'clock, angles grow clockwise, and dirX and dirY add TOP before turning an angle into screen cosine and sine.

    function layout(values) {
        let total = 0;
        for (let i = 0; i < values.length; i += 1) {
            total += values[i];
        }
        const out = [];
        let acc = 0;
        for (let i = 0; i < values.length; i += 1) {
            const a0 = (acc / total) * TAU;
            acc += values[i];
            out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
        }
        return out;
    }
    function dirX(a) { return Math.cos(TOP + a); }
    function dirY(a) { return Math.sin(TOP + a); }

Feeding in the seed 467 values gives shares of 7.1, 23.5, 5.9, 14.1, 22.4, 7.1 and 20.0% of 170. Seven scenes build their angles with this function; 04 keeps its own fixed-order running sum in anglesOf, and 07 re-divides each parent range by its children's shares in split. A donut chart javascript example can look like a separate chart type, yet in this source the two differ by a single inner radius handed to wedgePath, which closes the wedge at the center point when that radius is 0.5px or less and cuts a ring segment when it is larger.

Why Does a Slice Pop Out Along the Cosine and Sine of Its Middle Angle?

A slice's middle angle, midOf(i) = (a0 + a1) ÷ 2, is the line that splits the wedge into two equal halves. 02 shifts the whole slice by x pixels toward (dirX(m), dirY(m)); that direction is a unit vector, so x is the distance traveled, and the slice moves straight away from the center while opening gaps of equal width on both sides. Push it along any other line and it pulls away from one neighbor while riding up over the other.

Going the other way, finding the slice under the pointer means taking the angle of that point and checking which start and end angles it falls between. angleAt below takes the screen angle from Math.atan2(y − cy, x − cx), subtracts TOP and wraps the result into 0 to 2π so it lines up with the angles from layout. All nine scenes choose slices with this one angle; 03 first undoes the tilted projection to recover flat coordinates, and 07 takes the angle first and then turns it back into a share of the range in view to find the node.

    // Angle under the pointer — moves the atan2 screen angle into 0 to 2π measured from 12 o'clock
    function angleAt(x, y, cx, cy) {
        let a = Math.atan2(y - cy, x - cx) - TOP;
        while (a < 0) {
            a += TAU;
        }
        while (a >= TAU) {
            a -= TAU;
        }
        return a;
    }

01A Pie Chart That Sweeps In Clockwise, One Slice After Another

On a full circle rather than a donut, seven slices each grow from their own start angle, 80ms after the one before, so gaps open between slices and then close; the radius widens from 0.9R to R alongside them, and a bright line rides each opening end angle. The full draw takes (7 − 1) × 80 + 900 = 1,380ms, and with easeOutCubic each slice has covered 87.5% of its angle at the halfway point of its own time, while every round raises the seed by one and redraws with new sample values. It fits a report opener that reveals the shares once, and the render measured 22.6% cumulative area, 122.5 mean intensity and 19/23 moving frames.

A Pie Chart That Sweeps In Clockwise, One Slice After Another
Open in a new tab

01 · A Pie Chart That Sweeps In Clockwise, One Slice After Another — Measured area 22.602% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWEEP_MSSLICE_STAGGERDATA_SEED
    function progress(i) {
        if (elapsed < 0) {
            return 0;
        }
        return easeOutCubic((elapsed - i * SLICE_STAGGER) / SWEEP_MS);
    }

02A Pie Chart Whose Hovered Slice Springs Out Along Its Center Line

Only the slice under the pointer, or the one pinned by a click, receives the 16px target, and one spring value x feeds its position, brightness and shadow offset together so the slice appears to lift off the page. With SPRING_K 170 and SPRING_DAMP 14 the damping ratio is 14 ÷ (2√170) = 0.537, so the slice overshoots by 11.3% to 17.82px at 272ms and stays within ±0.5px from 400ms on, and slices are painted from least to most extended so the raised one is not hidden by its neighbors. It works on dashboards where people point at a slice to read its share, and the render measured 25.6% cumulative area, 84.0 mean intensity and 23/23 moving frames.

A Pie Chart Whose Hovered Slice Springs Out Along Its Center Line
Open in a new tab

02 · A Pie Chart Whose Hovered Slice Springs Out Along Its Center Line — Measured area 25.572% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

EXPLODE_DISTSPRING_KSPRING_DAMP
    function springStep(s, k, d, dt) {
        const acc = -k * (s.x - s.to) - d * s.v;
        s.v += acc * dt;
        s.x += s.v * dt;
    }
    // Distance (px) the chosen slice is pushed along its middle angle (cosine, sine)
    const EXPLODE_DIST = 16;

03A 3D Pie Chart That Tilts the Disc and Draws Its Side Wall

The disc leans back 55°, squashing its vertical axis to cos 55° = 0.574, and the 26px thickness, scaled by the sine, appears as a 21.3px side wall on screen. That wall is cut into 4° bands whose brightness, between 0.55 and 1, comes from the dot product of each band's normal with a light from the front left, and slices whose middle angle points toward the back are painted first, painter's-algorithm style, so the front ones cover them. It fits a presentation cover that wants depth, pointer height swings the tilt anywhere from 20° to 75°, and the render measured 19.7% cumulative area, 85.1 mean intensity and 23/23 moving frames.

A 3D Pie Chart That Tilts the Disc and Draws Its Side Wall
Open in a new tab

03 · A 3D Pie Chart That Tilts the Disc and Draws Its Side Wall — Measured area 19.733% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILT_DEGDEPTH_PXSIDE_SHADE
    function proj(px, py, z) {
        return [CX + px, CY + py * Math.cos(tilt) + (DEPTH_PX / 2 - z) * Math.sin(tilt)];
    }
    function quad(p, c) {
        ctx.beginPath();
        ctx.moveTo(p[0][0], p[0][1]);
        for (let k = 1; k < p.length; k += 1) {
            ctx.lineTo(p[k][0], p[k][1]);
        }
        ctx.closePath();
        ctx.fillStyle = rgba(c, 1);
        ctx.fill();
        // Fill the hairline seams between neighboring bands with a stroke of the same color
        ctx.strokeStyle = rgba(c, 1);
        ctx.lineWidth = 0.8;
        ctx.stroke();
    }
    // Side brightness — dot product of the in-plane normal (nx, ny) and the light; facing away darkens by up to SIDE_SHADE
    function sideColor(i, nx, ny) {
        const f = 1 - SIDE_SHADE * (1 - Math.max(0, nx * LIGHT_X + ny * LIGHT_Y));
        const base = mix(PAL[i], BG, 0.2);
        return [base[0] * f, base[1] * f, base[2] * f];
    }

04A Pie Chart That Glides Its Slice Angles to New Shares When the Period Changes

Each of the three periods holds values for only five of the seven items, so every switch grows two items in from zero width and shrinks two others out to zero. apply interpolates the start and end angles from the fixed-order anglesOf separately over 700ms with easeInOutPow(t, 3), and because neighbors share their boundary the total stays exactly 360° with zero boundary mismatch even in the middle of the morph. It suits monthly or quarterly share cards, and the render measured 23.7% cumulative area, 84.4 mean intensity and 20/23 moving frames.

A Pie Chart That Glides Its Slice Angles to New Shares When the Period Changes
Open in a new tab

04 · A Pie Chart That Glides Its Slice Angles to New Shares When the Period Changes — Measured area 23.678% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

MORPH_MSDATA_SETSEASE_POW
    function anglesOf(values) {
        let total = 0;
        for (let j = 0; j < ITEM_N; j += 1) {
            total += values[j];
        }
        const a0 = [];
        const a1 = [];
        let acc = 0;
        for (let j = 0; j < ITEM_N; j += 1) {
            a0.push((acc / total) * TAU);
            acc += values[j];
            a1.push((acc / total) * TAU);
        }
        return [a0, a1];
    }
    function apply() {
        const e = easeInOutPow(morphMs / MORPH_MS, EASE_POW);
        for (let j = 0; j < ITEM_N; j += 1) {
            curA0[j] = lerp(fromA0[j], toA0[j], e);
            curA1[j] = lerp(fromA1[j], toA1[j], e);
        }
    }

05A Pie Chart With Leader-Line Labels That Push Apart When They Overlap

Six small slices of 1.4 to 3.3% sit just past 12 o'clock, so labels left at their elbow heights would overlap in five pairs, and relaxPass lines each side's labels up by height and moves any neighbors closer than 16px apart by half the shortfall in opposite directions. One pass runs every 40ms, and the overlapping pairs reach zero on the 15th pass, 600ms in, with the farthest label traveling 29.3px. It fits reports with many categories, and since the circle holds still while only labels move, the render measured 5.3% cumulative area, 90.6 mean intensity and 18/23 moving frames.

A Pie Chart With Leader-Line Labels That Push Apart When They Overlap
Open in a new tab

05 · A Pie Chart With Leader-Line Labels That Push Apart When They Overlap — Measured area 5.303% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

LABEL_GAPRELAX_ITERSELBOW_LEN
    function relaxPass() {
        let moved = 0;
        for (const side of [-1, 1]) {
            const g = group(side);
            for (let j = 0; j + 1 < g.length; j += 1) {
                const d = g[j + 1].y - g[j].y;
                // Counts as done once within 0.25px — pushing by halves only creeps toward the gap without reaching it
                if (d < LABEL_GAP - 0.25) {
                    const push = (LABEL_GAP - d) / 2;
                    g[j].y -= push;
                    g[j + 1].y += push;
                    moved += 1;
                }
            }
            for (let j = 0; j < g.length; j += 1) {
                g[j].y = clamp(g[j].y, lo(side), hi(side));
            }
        }
        return moved;
    }

06A Pie Chart That Unrolls Into a Bar Chart, Slice by Slice

Each slice outline is sampled as 48 points, 12 from the center out to the start angle, 24 along the arc and 12 from the end angle back in, and each bar outline is sampled the same way, 12 up the left side, 24 across the top and 12 down the right. Joining point k to point k with straight-line interpolation carries the polar wedge over to the rectangular bar, each slice setting off 50ms after the previous one and moving for 450ms. Reach for it when one card has to switch between share and size comparison, and the render measured 26.3% cumulative area, 156.5 mean intensity and 19/23 moving frames.

A Pie Chart That Unrolls Into a Bar Chart, Slice by Slice
Open in a new tab

06 · A Pie Chart That Unrolls Into a Bar Chart, Slice by Slice — Measured area 26.331% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

UNROLL_MSBAR_GAPOUTLINE_SAMPLES
    function wedgeOutline(s) {
        const n = OUTLINE_SAMPLES;
        const q = Math.floor(n / 4);
        const out = [];
        for (let k = 0; k < n; k += 1) {
            let r = R;
            let a = s.a0;
            if (k < q) {
                r = (R * k) / q;
            }
            else if (k < n - q) {
                a = s.a0 + ((s.a1 - s.a0) * (k - q)) / (n - 2 * q);
            }
            else {
                r = R * (1 - (k - (n - q)) / q);
                a = s.a1;
            }
            out.push([CX + dirX(a) * r, CY + dirY(a) * r]);
        }
        return out;
    }

07A Sunburst That Splits Into Inner and Outer Rings and Expands a Branch on Click

A three-level tree of 4, 9 and 22 nodes stores each range as a 0 to 1 share instead of an angle, split re-divides a parent range by its children's values, and at draw time mapA stretches the range in view, [v0, v1], across 0 to 2π while pinning slices outside it to the ends at zero width. Pressing a ring interpolates v0 and v1 over 650ms, and in the demo the largest branch, B at 29.0%, widens 3.446 times until it fills the circle. It suits revenue broken into categories and subcategories, and the render measured 26.0% cumulative area, 79.8 mean intensity and 18/23 moving frames.

A Sunburst That Splits Into Inner and Outer Rings and Expands a Branch on Click
Open in a new tab

07 · A Sunburst That Splits Into Inner and Outer Rings and Expands a Branch on Click — Measured area 25.982% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

RING_WIDTHDRILL_MSLEVELS
        function split(n) {
            let acc = n.f0;
            for (let i = 0; i < n.kids.length; i += 1) {
                const c = n.kids[i];
                c.f0 = acc;
                acc += ((n.f1 - n.f0) * c.value) / n.value;
                c.f1 = acc;
                split(c);
            }
        }
    let v0 = 0;
    let v1 = 1;
    let from0 = 0;
    let from1 = 1;
    let drillMs = DRILL_MS;
    let pick = -1;
    // Share to angle — stretches the range in view [v0, v1] to 0 to 2π; anything outside sticks to the ends at zero width
    function mapA(f) { return clamp((f - v0) / (v1 - v0), 0, 1) * TAU; }
    function nameOf(n) { return n.depth === 1 ? LABELS[n.top] : n.code; }
    function ring0(d) { return HOLE_R + (d - 1) * RING_WIDTH; }

08A Pie That Springs Open Into a Donut Chart While the Total Counts Up

Pressing Make it a donut sends the inner radius ratio from 0 toward 0.55 on a spring with a damping ratio of 0.408, so the hole overshoots to 0.676 at 256ms and comes to rest at 656ms. The center total counts from 0 to 1,786 with easeOutCubic for 900ms starting the moment the hole begins to open, and choosing a slice restarts the count toward that slice's value. It belongs on share cards that put a KPI total in the middle, and the render measured 22.9% cumulative area, 103.2 mean intensity and 22/23 moving frames.

A Pie That Springs Open Into a Donut Chart While the Total Counts Up
Open in a new tab

08 · A Pie That Springs Open Into a Donut Chart While the Total Counts Up — Measured area 22.868% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

HOLE_RATIOHOLE_SPRINGCOUNT_MS
    function setOpen(on) {
        open = on;
        hole.to = on ? HOLE_RATIO : 0;
        if (on) {
            shown = 0;
            countToward(target());
        }
        if (holeBtn) {
            holeBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
        }
    }

09A Pie Chart Scene Where the Pointer Acts as a Light Over Every Slice

The slices are painted in flat colors and read once with getImageData for their base colors, each pixel gets a baked height (a rounded 9px lip at the rim plus a dome rising 14px toward the middle) and a normal, and then every frame walks all 61,504 pixels of the 248×248 board, relighting the 42,142 that sit on a slice with diffuse and gloss terms from a light hovering 120px above the pointer. At a gloss exponent of 28 the highlight drops to half once the normal and the half vector are 12.7° apart, and grain noise of ±8 lays a fine texture over the pastel faces. It suits a landing-page metric scene where the chart doubles as decoration, and the render measured 22.0% cumulative area, 26.6 mean intensity and 23/23 moving frames.

A Pie Chart Scene Where the Pointer Acts as a Light Over Every Slice
Open in a new tab

09 · A Pie Chart Scene Where the Pointer Acts as a Light Over Every Slice — Measured area 21.997% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LIGHT_HEIGHTGLOSS_POWGRAIN_AMT
    function shade() {
        const o = outImg.data;
        const lx = lightX - BX0;
        const ly = lightY - BY0;
        for (let y = 0; y < BS; y += 1) {
            for (let x = 0; x < BS; x += 1) {
                const p = y * BS + x;
                const q = p * 4;
                const al = alpha[p];
                if (al <= 0) {
                    o[q + 3] = 0;
                    continue;
                }
                const nx = normals[p * 3];
                const ny = normals[p * 3 + 1];
                const nz = normals[p * 3 + 2];
                let vx = lx - x;
                let vy = ly - y;
                let vz = LIGHT_HEIGHT - heightMap[p];
                const inv = 1 / Math.sqrt(vx * vx + vy * vy + vz * vz);
                vx *= inv;
                vy *= inv;
                vz *= inv;
                const diff = Math.max(0, nx * vx + ny * vy + nz * vz);
                // Half vector — halfway between the light direction and the view direction (straight on, 0, 0, 1)
                const hz = vz + 1;
                const hinv = 1 / Math.sqrt(vx * vx + vy * vy + hz * hz);
                const nh = Math.max(0, (nx * vx + ny * vy + nz * hz) * hinv);
                const spec = Math.pow(nh, GLOSS_POW) * SPEC_K * 255;
                // Letting the diffuse term reach 1.12 makes spots facing the light a little brighter than the base color
                const lit = (AMBIENT + (1.12 - AMBIENT) * diff) * (pick >= 0 && sliceId[p] === pick ? 1.12 : 1);
                const g = grain[p] * GRAIN_AMT;
                o[q] = baseRGB[p * 3] * lit + spec + g;
                o[q + 1] = baseRGB[p * 3 + 1] * lit + spec + g;
                o[q + 2] = baseRGB[p * 3 + 2] * lit + spec + g;
                o[q + 3] = 255 * al;
            }
        }
        lctx.putImageData(outImg, 0, 0);
        dirty = false;
    }

Thin Slices, Crowded Labels, Narrow Screens — Where a Pie Chart Breaks

The figures in this section come from calling the source functions directly or from recomputing the formulas in the source; how long a frame takes on any particular device was outside what was measured.

Share labels on thin slices wait for the sweep to finish

Writing percentages while 01 is still opening would push the text past the edges of small wedges, so labels go only on slices of 6% or more whose progress has reached 1. That is why the third slice, 5.9% under seed 467, carries no number. The stagger has a ceiling too: once (slice count − 1) × 80ms grows past 900ms, the first slice is finished before the last one starts and the sweep breaks into separate steps, whereas the present 480ms keeps all seven growing together between 480ms and 900ms.

A popped slice jitters at its outer tip

If the hit test only reaches the original radius R, pointing at the outer tip of a popped slice (R + x) releases the hover, the slice drops back, the hover catches again and the slice jitters. hitSlice in 02 looks out to R + x + 4 to stop that loop. Raising the damping ratio to 1 or above removes the bounce in favor of a slow approach, and dropping it below 0.2 lets the slice swing back and forth several times until the chart itself looks unsteady.

Labels pinned at the edge of their range slow the push

A label pressed against the top boundary cannot move higher, so only its lower neighbor gives way by halves and the push converges more slowly. An early layout of 05 placed the small slices at the upper left, where overlapping pairs remained after every pass had run, and moving them to the upper right let them clear in 15 passes. Halving creeps toward the gap without ever landing on it, so testing for exactly 16 would spend all 40 allowed passes, which is the reason for the 0.25px tolerance.

Painting in data order lifts rear walls over the front

03 sorts slices by the sine of their middle angle and paints from the back of the screen forward. In plain data order, the side wall of a rear slice would land on top of a front slice. One middle angle per slice can misorder part of a wall once a slice spans more than 180°, but the largest slice in this data is 26.3%, so that case does not come up.

Pair items by identity, not by array position

In 04, pairing the remaining slices by array position from one period to the next would hand a color to the wrong item and send the shares drifting toward the wrong side. Interpolating a start angle and a width separately still leaves gaps or overlaps between neighbors, so both boundaries are interpolated and neighbors share them. Pressing again mid-transition would jump if the morph restarted from the target period's first angles, so goPeriod sets off from the angles currently on screen.

Point counts and winding direction have to agree

The two outlines in 06 must both come from the same OUTLINE_SAMPLES for the points to pair up. If one outline winds the opposite way, the points cross each other in flight and the in-between shape twists, and cutting the count to around 12 points turns the arc of a big slice into a visible polygon. With the current 48 points, the widest step between arc points is 4.2°.

Stretched angles need a clamp

When 07 widens the range in view, slices outside it would run past 2π and wrap around the circle more than once unless their angles were cut. The clamp inside mapA pins those slices to the ends at zero width. Adding a fourth level would put the outer ring radius at 28 + 4 × 34 = 164px, a diameter taller than the 300px stage.

A closing spring that dips below zero makes arc throw

When the hole in 08 closes, the spring overshoots past zero as well. A negative radius passed to arc throws an exception and halts the frame loop, which happened in an early build. So the inner radius is clipped to R × max(0, x) and the hole's rim line is drawn only at 5px or more; keep in mind as well that the count runs for 900ms while the hole settles at 656ms, so the two do not end together.

Reading pixels every frame holds the loop back

If 09 reread the base colors with getImageData on each frame, requestAnimationFrame would fall behind in headless renders or on low-end devices. The base colors are read once at bake time and kept in arrays, and the lighting runs on the 1x board instead of the real 2x pixels, cutting the work to a quarter before the board is scaled up. The trade-off is a slightly softer look on 2x screens, and an earlier, heavier grain made the pastel faces blotchy, which is how the amount settled at 8.

Taps still land on the right slice on a narrow screen

The stage width is min(92vw, calc(160vh − 12.8vw)) at a 480:300 ratio, so a narrow screen shrinks the whole canvas. Internal coordinates stay at 480×300, and the pointer is mapped into scene space in proportion to getBoundingClientRect, so the slice you touch on a smaller circle is the slice that gets picked. The backing pixels multiply by the device ratio capped at 2, and at 340px wide or less the button and status-line text drop to 10px.

When you want to drop in your own numbers and spring values and run the scenes on your machine, the archive opens with the password yukp4mxx. Every scene folder carries its vanilla source, and React versions that port the same math come in the same download.

Changing Pop Distance, Tilt Angle and Hole Size, Plus Accessibility Beyond Color

Variant Value Feel
Push the pop-out distance further 02 EXPLODE_DIST 16 → 30 The slice moves from 14.3% to 26.8% of the 112px radius away and clearly leaves the circle. The spring is untouched, so the overshoot ratio stays at 11.3% with its peak at 272ms whatever the distance; only the overshoot itself grows from 1.82px to 3.40px.
Raise the tilt angle 03 TILT_DEG 55 → 70 The vertical ratio flattens from 0.574 to 0.342, shrinking the vertical radius from 64.2px to 38.3px. The visible wall only grows from 21.3px to 24.4px, yet the wall-to-top ratio climbs from 0.332 to 0.638, so the disc reads almost twice as thick.
Widen the donut hole 08 HOLE_RATIO 0.55 → 0.75 The ring thins from 45% to 25% of the radius and the painted area falls from 69.75% to 43.75%, freeing room for the center number. The overshoot stays at 22.9% at 256ms, and at its peak the hole opens to 103.2px, leaving a ring only 8.8px wide.

Slices are not told apart by color alone. Choosing one makes the status line spell out its name and share, such as "Item B · 27.7%" (07 uses branch codes like "B-2 · 11.9%"), and the chosen slice also shows itself through the pop distance in 02, a 10px lift in 03 and the center number in 08. 01 writes the share directly on finished slices of 6% or more.

All nine scenes work without a mouse. Each canvas has tabindex="0" and an aria-label, so it takes focus, and in every scene ←/→ moves between slices and Escape releases the choice (in 07, Escape and Backspace go up one level). There are 19 real <button> elements, three in 04 for Period 1, 2 and 3 and two in each of the other scenes; the period buttons in 04 and the toggle buttons in 02, 06, 08 and 09 report their state through aria-pressed. 03 also changes the tilt in 5° steps with ↑/↓.

The status line stays silent with aria-live="off" during the automatic demo and switches to polite the first time someone touches the pointer, a key or a button, so only choices made after that are announced. Each scene reads prefers-reduced-motion once when it starts and adds no change listener, so flipping the system setting shows up after a reload. With reduced motion on, none of the nine starts its frame loop; settle() builds the end state and draw() runs once, input still works, and each press has after() compute the finished result and repaint, while Enter in 01 and 05 leads straight to the end state and leaves the same picture on screen.

Is a CSS 3D Pie Chart Possible With a Conic Gradient Alone?

A single still pie chart can be drawn with CSS by itself. conic-gradient paints color by angle range, so putting the cumulative angles in as color stops yields the same wedges layout computes, all in one background. The nine scenes in this post, though, are canvas drawings driven by JavaScript math, not CSS. One background gives no slice that can be lifted out on its own, so the pop-out in 02, the side wall in 03 and the bar transition in 06 have nothing to move, and telling which slice the pointer is on still needs an atan2 calculation like angleAt. The label push in 05, the count in 08 and the per-pixel lighting in 09 are repeated calculations as well, which puts them on the script side.

Other charts from the same data-visualization line live in nine SVG chart drawings, nine canvas chart animations and nine computed data-visualization chart types. For redrawing a chart from one changed value, see the graph animation picker, and for scenes built from per-pixel math, the nine canvas scene effects are the closest relatives.

Pie Chart Questions From People Wiring Up a Dashboard

Does this pie chart JS example need a chart library?

No. The nine scenes use no library, CDN or WebGL, only arc, path fills and requestAnimationFrame, with 09 adding getImageData and putImageData for its per-pixel lighting. Angles come from the running division in layout and picking comes from the single atan2 call in angleAt, so for a pie chart without axes or a legend those two functions are a solid place to begin.

Where do my own numbers go?

01, 02, 03, 08 and 09 generate sample values in one line, layout(seededValues(seed, SLICE_N, 6, 40)) in 01 with a different low and high bound in the other four, so passing your own array to layout in that spot is enough. If the slice count changes, update SLICE_N to match, and past nine slices extend the LABELS name array as well. In 04 the spot is buildSets, which builds the per-period arrays, in 05 it is makeValues, in 06 it is the seededValues call inside setData, and in 07 it is buildTree, which builds the grouped tree.

How does a screen reader announce the slice shares?

With focus on the canvas, moving between slices with ←/→ makes the status line read the chosen slice's name and share as one line. Nothing is read while the automatic demo runs, and announcements begin after the first key press, so the demo's motion does not set off a stream of speech.

Enter the archive password

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