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

Bar Chart Animation: 9 Demos From Spring to Race

When bars jump straight to new values, the eye misses what grew. So 01 grows bars on a spring that overshoots once, 02 swaps lanes as ranks flip, and 07 shades

No charting package is loaded anywhere on this page. Each chart is one <canvas> whose bars, ticks and labels JavaScript recomputes every 16ms, and the stylesheet only lays out the stage, the buttons and the focus rings.

A Board Where Nine Bars Grow and Race on Made-Up Sales Numbers

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

Where the bar heights come from

Every height is a made-up number drawn from makeRng(seed), a 32-bit integer mixing function seeded with 470 and 4702 to 4709, so reopening the page brings back the same bars at the same heights. The labels are placeholders as well: "Item A" to "Item J", #1 to #60 in 06, A1 to D4 in 07 and plain bin numbers in 08. None of the nine files calls a random function or reads the clock, so pressing the buttons in the same order reproduces every millisecond and pixel figure quoted below.

Which report screen each chart suits

A sales dashboard that counts up on first load fits 01, while a monthly ranking that keeps reshuffling fits 02, which is the bar chart race of the set. When one bar holds several shares, like visits split by channel, 03 hands each lower layer's sum up as the floor of the next one, and survey questions with long names sit better on the horizontal bars of 04.

A visitor count that climbs live suits the re-picked ticks of 05, and a year of daily records packed shoulder to shoulder suits the fisheye of 06. 07 puts regional figures in 3D on a landing screen, 08 shows where response times cluster, and 09 sums up month-over-month gains and losses on a single card.

Bar Chart Animation: 9 Ways — Spring Growth, Rank Race, Stacked Layers, Horizontal Labels, Axis Rescale, Fisheye Zoom, Isometric Columns, Particle Histogram and Diverging Baseline

The order begins with the moment bars first rise (01), moves through values that change and pile up over time (02, 03), then labels and ticks that pick their own spot (04, 05), then looking into the chart with a pointer and a light (06, 07), and closes with charts whose bar shapes describe a distribution and a change (08, 09). Every frame is drawn with 2D calls on a single canvas per chart, and a requestAnimationFrame loop that advances in fixed 16ms steps moves everything. None of the nine style.css files contains a @keyframes rule, and the only CSS transition left is a button background color.

Two seconds of capture, each frame checked against its neighbor

Every chart was recorded at 480×300 for 2 seconds, giving 24 frames per chart. The last column counts how many of the 23 neighbor-to-neighbor comparisons found a changed screen, which is why 23 sits under each figure.

Chart Changed area (cumulative) Mean intensity Moving frames
01 spring growth 34.1% 101.8 23/23
02 rank race 42.2% 131.1 23/23
03 stacked layers 18.4% 145.5 22/23
04 horizontal labels 16.5% 104.5 23/23
05 axis rescale 35.7% 140.5 23/23
06 fisheye zoom 40.1% 72.0 23/23
07 isometric columns 16.8% 61.2 23/23
08 particle histogram 24.9% 105.2 23/23
09 diverging baseline 36.4% 100.7 23/23

No chart dropped under 10% cumulative area. 04 and 07 are the narrowest, around 16%, probably because 04 changes only bar tips and digits and 07 changes only face brightness and shadows while its grid stays put. 07 also has the lowest mean intensity of the nine, 61.2, likely because its broad faces brighten only a little at a time.

01Bars That Grow on a Spring, Overshooting Once Before Settling

Each bar keeps its own height and velocity, and every 16ms step adds a pull toward the target (SPRING_STIFFNESS 170) and a drag on the velocity (SPRING_DAMPING 12), changing velocity first and height second, while starts are staggered by 70ms so the eighth bar begins moving only at 490ms. With a damping ratio of 12 ÷ (2√170) = 0.46, a bar aimed at 60 peaks at 70.6 at 256ms, overshooting the target by 17.7%, and stays within ±0.5 of the target from 640ms on; faster bars are painted brighter and the slice above the target line gets an extra coat so the overshoot catches the eye. It suits the opening chart of a sales dashboard, and every one of the 23 comparisons in the 2-second capture showed change.

Bars That Grow on a Spring, Overshooting Once Before Settling
Open in a new tab

01 · Bars That Grow on a Spring, Overshooting Once Before Settling — Measured area 34.125% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_STIFFNESSSPRING_DAMPINGGROW_STAGGER_MS
    function springStep(s, k, d, dt) {
        const a = -k * (s.x - s.to) - d * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    // Damped spring — a pulling force (1/s²) and a braking force (1/s). Damping ratio = SPRING_DAMPING / (2√SPRING_STIFFNESS) is below 1, so the bar passes the target once and comes back
    const SPRING_STIFFNESS = 170;
    const SPRING_DAMPING = 12;
    // Start stagger — each bar from the left begins pulling this much later
    const GROW_STAGGER_MS = 70;

02A Bar Chart Race Where Bars Switch Lanes as Ranks Flip

Nine made-up entries are interpolated linearly across 8 keyframes 420ms apart, re-sorted by size on every 16ms step and trimmed to the top six (TOP_N), and each bar's vertical position moves 12% of the remaining gap toward its new rank so lane changes glide instead of snapping. Closing a one-lane gap to 10% takes 19 steps, or 304ms, which finishes before the next keyframe; a bar that climbs glows briefly and the glow cools by a factor of 0.95 per step, and although the axis length trails behind, it is held no lower than the current top value so the leading bar stays inside BAR_X1 at x 420. It fits an intro screen walking through monthly rank changes, and its cumulative area of 42.2% is the widest of the nine.

A Bar Chart Race Where Bars Switch Lanes as Ranks Flip
Open in a new tab

02 · A Bar Chart Race Where Bars Switch Lanes as Ranks Flip — Measured area 42.181% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RACE_FRAME_MSRANK_LERPTOP_N
    // Re-sort every step — rank by value from largest, and a bar that moved up glows for a moment
    function reRank() {
        const order = [];
        for (let i = 0; i < ENTRY_N; i += 1) {
            order.push(i);
        }
        order.sort(function (a, b) { return cur[b] - cur[a]; });
        for (let r = 0; r < ENTRY_N; r += 1) {
            const i = order[r];
            if (r < rank[i] && r < TOP_N) {
                glow[i] = 1;
            }
            rank[i] = r;
        }
    }

03Stacked Bars That Build Up Layer by Layer From the Bottom

For each of six bars, the four series are added from the bottom up so every layer starts where the running total beneath it ends, and layers start 220ms apart and bars 40ms apart, each filling over 520ms on the cubic ease 1 − (1 − t)³ with a 2px gap between layers. Because that ease front-loads the motion, a layer is 90% full at 279ms of its 520ms, the top layer of the last bar finishes at 1,380ms, and pressing "Hide series 3" shrinks that layer's weight to 0 so the layers above settle down while the total on top is recounted from the same running sum. It belongs on a channel-by-channel traffic report, and its 22/23 moving frames, one short of the rest, likely come from the brief pause after the stack completes and before the series is hidden.

Stacked Bars That Build Up Layer by Layer From the Bottom
Open in a new tab

03 · Stacked Bars That Build Up Layer by Layer From the Bottom — Measured area 18.397% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

SEGMENT_DELAY_MSSEGMENT_EASE_POWSTACK_GAP_PX
    // Fill amount of layer k (0 to 1) — starts SEGMENT_DELAY_MS later per layer and COL_STAGGER_MS later per bar
    function fill(c, k) {
        const t = clamp((buildMs - k * SEGMENT_DELAY_MS - c * COL_STAGGER_MS) / FILL_MS, 0, 1);
        return 1 - Math.pow(1 - t, SEGMENT_EASE_POW);
    }
    // Value shown right now — original value × fill × series visibility weight
    function shown(c, k) { return values[c][k] * fill(c, k) * weight[k]; }
    // Running sum — layer k starts at the sum of the visible values of the layers below
    function baseOf(c, k) {
        let s = 0;
        for (let j = 0; j < k; j += 1) {
            s += shown(c, j);
        }
        return s;
    }
    function totalOf(c) { return baseOf(c, SERIES_N); }

04Horizontal Bars Whose Labels Decide to Sit Inside or Outside

The number at each bar's tip is measured with measureText to see whether it fits inside the bar with 8px of padding on both sides, and the bar color's relative luminance gives the contrast against both a light and a dark text color, so the label goes inside, in the better of the two colors, only when that contrast reaches CONTRAST_MIN 4.5. Item D's grayish violet bar (128, 120, 150) scores 3.86 with light text and 3.95 with dark text, both short of 4.5, so with the first data set "15,940" sits outside even though the bar is a roomy 234px long. It is meant for charts whose bars differ a lot in length, like survey results with long item names, and its 16.5% cumulative area is the smallest of the nine.

Horizontal Bars Whose Labels Decide to Sit Inside or Outside
Open in a new tab

04 · Horizontal Bars Whose Labels Decide to Sit Inside or Outside — Measured area 16.487% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LABEL_PAD_PXCONTRAST_MINBAR_THICKNESS_PX
    // Relative luminance — linearize each sRGB channel and mix with 0.2126·0.7152·0.0722
    function channel(v) {
        const c = v / 255;
        return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    }
    function luminance(c) { return 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]); }
    function contrast(a, b) {
        const la = luminance(a);
        const lb = luminance(b);
        return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
    }
    // Placement — inside when text width plus padding on both sides fits the bar and the better of the two text colors passes CONTRAST_MIN
    function placeLabel(text, len, bar) {
        ctx.font = '700 12px sans-serif';
        const width = ctx.measureText(text).width;
        const light = contrast(bar, LIGHT_TEXT);
        const dark = contrast(bar, DARK_TEXT);
        const best = light >= dark ? LIGHT_TEXT : DARK_TEXT;
        const ratio = Math.max(light, dark);
        const fits = width + LABEL_PAD_PX * 2 <= len;
        return { inside: fits && ratio >= CONTRAST_MIN, text: best, ratio: ratio, width: width };
    }

05An Axis That Re-Picks 1-2-5 Ticks and Shrinks Along With the Bars

The largest value is split into five slots (TICK_TARGET_COUNT), that rough step is scaled by a power of ten and rounded up to the first of 1, 2, 5 or 10 that covers it, and only when the axis end changes do the old ticks fade out and the new ones fade in over 600ms while the bars, divided by the same maximum, shrink alongside them. Pressing "Raise values" lifts the maximum 46 → 118 → 405 → 922 while the step is re-picked as 10 → 50 → 100 → 200 and the axis end as 50 → 150 → 500 → 1,000. It fits a visitor chart whose numbers grow live, and its mean intensity of 140.5 is second only to 03.

An Axis That Re-Picks 1-2-5 Ticks and Shrinks Along With the Bars
Open in a new tab

05 · An Axis That Re-Picks 1-2-5 Ticks and Shrinks Along With the Bars — Measured area 35.748% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TICK_TARGET_COUNTNICE_STEPSRESCALE_MS
    // 1-2-5 staircase — normalize the rough step by a power of ten and round up to the first NICE_STEPS entry that covers it
    function niceStep(maxValue) {
        const raw = maxValue / TICK_TARGET_COUNT;
        const mag = Math.pow(10, Math.floor(Math.log10(raw)));
        const norm = raw / mag;
        for (let i = 0; i < NICE_STEPS.length; i += 1) {
            if (norm <= NICE_STEPS[i] + 1e-9) {
                return NICE_STEPS[i] * mag;
            }
        }
        return 10 * mag;
    }
    function niceMax(maxValue) {
        const s = niceStep(maxValue);
        return Math.ceil(maxValue / s - 1e-9) * s;
    }

06A Dense Bar Chart Where Bars Near the Pointer Widen Like a Fisheye Lens

For every edge of the 60 bars (BAR_COUNT), the distance to the pointer divided by an 80px radius gives u, which g(u) = (P+1)u ÷ (Pu+1) pushes outward, while edges beyond the radius stay where they are, so the 432px total width does not change. A bar that starts at 7.2px can widen by no more than the curve's starting slope, P+1 = 4, and with the focus in the middle of the chart 8 bars widen (the widest reaches 22.7px, about 3.2 times) while 16 get squeezed at the rim of the radius. It suits zooming into one stretch of a dense yearly chart of daily records, and with the focus sweeping across and back once every 2 seconds, all 23 comparisons registered motion.

A Dense Bar Chart Where Bars Near the Pointer Widen Like a Fisheye Lens
Open in a new tab

06 · A Dense Bar Chart Where Bars Near the Pointer Widen Like a Fisheye Lens — Measured area 40.077% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAR_COUNTFISHEYE_RADIUS_PXFISHEYE_POWER
    // Fisheye — the distance from the pointer to x, divided by the radius, gives u, which g(u) pushes outward. Outside the radius nothing moves, so the outer positions stay continuous
    function fisheye(x, fx, power) {
        const d = x - fx;
        const ad = Math.abs(d);
        if (ad >= FISHEYE_RADIUS_PX || power <= 0) {
            return x;
        }
        const u = ad / FISHEYE_RADIUS_PX;
        const g = ((power + 1) * u) / (power * u + 1);
        return fx + Math.sign(d) * FISHEYE_RADIUS_PX * g;
    }
    function edge(i) { return fisheye(PLOT_L + i * barW0, focusX, FISHEYE_POWER * lens); }

07Isometric 3D Columns Whose Top and Sides Shade by Light Direction

Every cell of a 4×4 grid gets a column raised in 30° isometric projection with a top and two sides, each face's brightness is the 0.3 ambient light plus the remaining 0.7 scaled by the dot product of the face normal with a light vector at azimuth LIGHT_AZIMUTH_DEG and 42° elevation, and cells with a smaller gx + gy are painted first. At the starting azimuth of 135° the +x side faces away from the light and gets only the 0.3 ambient, the +y side gets 0.668 and the top gets 0.768 at any azimuth, while floor shadows lie away from the light at 1.111px of length per 1px of column height. It suits a landing screen that shows regional figures in 3D, and because the light circles once every 2 seconds, 23/23 frames moved even though only 16.8% of the screen changed.

Isometric 3D Columns Whose Top and Sides Shade by Light Direction
Open in a new tab

07 · Isometric 3D Columns Whose Top and Sides Shade by Light Direction — Measured area 16.805% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ISO_ANGLE_DEGLIGHT_AZIMUTH_DEGDEPTH_PX
    // Isometric projection — floor point (wx, wy) and height wz to a screen point
    function project(wx, wy, wz) {
        return [OX + (wx - wy) * cosA, OY + (wx + wy) * sinA - wz];
    }
    // Light vector — unit vector (Lx, Ly, Lz) from azimuth and elevation
    function lightVec() {
        const az = azimuth * DEG;
        const el = LIGHT_ELEV_DEG * DEG;
        return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
    }
    // Face brightness — ambient light AMBIENT plus the normal-light dot product (0 when negative)
    function shade(nx, ny, nz, L) {
        return AMBIENT + (1 - AMBIENT) * Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
    }

08Falling Dots That Pile Into Bins and Become a Histogram

Averaging four uniform random numbers picks one of 15 bins for each of 360 dots dropped from the top, and a gravity of 1,400px/s² builds up speed until a dot reaches the height reserved for it, where the speed is multiplied by 0.35 to bounce, or the dot stops and settles once its speed is 70px per second or less. Averaging thickens the middle bin to a 17.4% share (pressing "One uniform random" gives roughly 6.7% per bin), in the seeded run the tallest bin collects 60 of the 360 dots, and a single dot falling 234.5px touches its spot three times before resting at 1,136ms. It suits a technical post explaining a response-time distribution, and it measured 24.9% cumulative area with a mean intensity of 105.2.

Falling Dots That Pile Into Bins and Become a Histogram
Open in a new tab

08 · Falling Dots That Pile Into Bins and Become a Histogram — Measured area 24.856% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DROP_GRAVITYBIN_COUNTBOUNCE_LOSS
        for (let i = 0; i < dots.length; i += 1) {
            const d = dots[i];
            if (d.rest) {
                continue;
            }
            d.v += DROP_GRAVITY * dt;
            d.y += d.v * dt;
            // On reaching its own spot (the pile height), bounce, or stop and settle once slow enough
            if (d.y >= d.restY) {
                d.y = d.restY;
                if (d.v > REST_SPEED) {
                    d.v = -d.v * BOUNCE_LOSS;
                }
                else {
                    d.v = 0;
                    d.rest = true;
                    landed[d.bin] += 1;
                }
            }
        }

09Diverging Bars Split Up and Down From a Zero Line That Moves With the Data

The axis range comes from comparing the minimum and maximum with 0 so that 0 stays inside it, then grows by 12% of the range above and below, and the zero line sits at the share of the full range that lies between the top of the axis and 0. When the data changes, both axis ends move 10% of the remaining distance per step, so switching from the first data set to the second lifts the baseline from y 235.3 to 81.4 and passes 90% of that trip within 368ms, while small values within ±8 of 0 blend the loss and gain colors toward gray. It fits a summary card of month-over-month change, and its 36.4% cumulative area likely comes from the baseline and the background bands above and below it moving together.

Diverging Bars Split Up and Down From a Zero Line That Moves With the Data
Open in a new tab

09 · Diverging Bars Split Up and Down From a Zero Line That Moves With the Data — Measured area 36.38% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASELINE_LERPZERO_CROSS_FADEDIVERGE_PAD_PCT
    // Axis range — include 0 (min(0,·)·max(0,·)) and widen above and below by DIVERGE_PAD_PCT of the value range
    function rangeFor() {
        let lo = 0;
        let hi = 0;
        for (let i = 0; i < BAR_N; i += 1) {
            lo = Math.min(lo, goalOf(i));
            hi = Math.max(hi, goalOf(i));
        }
        const pad = (hi - lo) * DIVERGE_PAD_PCT / 100;
        return { lo: lo - pad, hi: hi + pad };
    }
    const axis = rangeFor();
    // Zero line height — the share of the full range from the top of the axis down to 0
    function zeroY() { return PLOT_T + (axis.hi / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
    function yOf(v) { return PLOT_T + ((axis.hi - v) / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }

How Does One Value Become a Bar So Many Pixels Tall?

A bar's height is its value divided by the axis end, multiplied by the height of the plot area. In 01 the plot spans y 64 to 262, which is 198px, over an axis from 0 to 100, so one unit equals 1.98px and a bar aimed at 60 rises 118.8px off the floor. Its width is the 418px plot cut into eight slots of 52.25px, minus a 14px gap, for 38.25px.

When the axis end moves, the same value lands at a new height

The value is not the only moving part of that proportion. 05 slides the divisor, the axis end, from 50 to 150 over 600ms, and sampled every 100ms it reads 59.2 → 81.4 → 109 → 134 → 148.7, a curve that runs fastest in the middle. A value that stayed put would end up a third as tall. Since the bars are divided by the same number as the axis rather than shrunk on their own, gridlines and bar tips stay lined up.

A horizontal bar is the same formula laid along x

Any horizontal bar graph animation uses the identical proportion and only measures along the other axis. 04 maps x 70 on the left to 452 on the right onto 0 to 26,000, so one unit becomes 0.01469px and 15,940 becomes a bar 234px long.

A zero line that sits mid-chart

Once negatives enter, as in 09, the floor moves from the bottom edge to wherever 0 lands. The baseline y is 58 + hi ÷ (hi − lo) × 214; the first data set spans −10.78 to 52.08, which places 0 at 82.8% from the top at y 235.3, and the second spans −49.03 to 6.03, which raises it to the 10.9% mark at y 81.4.

Why the coordinates stay 480×300 on any screen

Inside every canvas the coordinate space is fixed at 480×300, and only the backing pixels are multiplied by Math.min(window.devicePixelRatio || 1, 2). Pointer positions are mapped back into that space through getBoundingClientRect ratios, so the pixel figures in this post read the same way after the stage shrinks to phone width.

Picking Ticks in Steps of 1, 2 and 5, and Why a Bar Overshoots Its Target Once Before Settling

Ticks spaced 23.6 or 184.4 apart leave a reader unable to divide a bar's height in their head. 05 first finds a rough step, scales it by a power of ten into a number between 1 and 10, takes the first of 1, 2, 5 and 10 that is equal or larger, and multiplies the power of ten back in.

The steps 05 chose at each of its four stages

Stage Largest bar Rough step Chosen step Axis end
Starting values 46 9.2 10 50
First raise 118 23.6 50 150
Second raise 405 81 100 500
Third raise 922 184.4 200 1,000

For 118, the rough step 23.6 divided by 10 is 2.36, which is above 2, so it climbs to 5 for a step of 50, and for 922 the normalized 1.844 climbs to 2 for a step of 200. Fed other inputs, the same function returns step 2 and axis 8 for 7, step 500 and axis 1,500 for 1,234, and step 1,000 and axis 5,000 for 4,321. The axis end is the maximum divided by the step, rounded up, times the step, so the chart stops at the tick just above the tallest bar, the way the axis for 922 ends at 1,000.

A damping ratio under 1 carries the bar past its target once

The acceleration in 01 has just two terms: −stiffness × (height − target) − damping × velocity. When the damping ratio, damping ÷ (2√stiffness), is below 1, the bar still carries upward speed as it reaches the target, so it overshoots and is pulled back; at 170 and 12 the ratio is 0.46 and the overshoot is 17.7%. The bar first enters the ±0.5 band at 416ms, slips out once more, and stays inside from 640ms.

Why changing velocity before height holds steady

springStep updates the velocity with the acceleration first and then moves the height with that new velocity. At a fixed 16ms step this order wobbles less than moving the height with the old velocity, which is why it was chosen, and at damping 12 the swing dies down until the bar stays within ±0.5 of the target from 640ms on.

Hundreds of Bars, an All-Zero Month, Labels Wider Than Their Bars — Where the Bar Charts Break

Hundreds of bars push the fisheye rim under 1px

The widths in 06 are bound to a 432px total, so widening the middle squeezes the rim of the radius; even with 60 bars the thinnest one shrinks to 2.0px and the gap between bars to 0.4px. Splitting 432px by the bar count keeps the starting width above 1px up to 432 bars, and it is still 2.2px at 200, but with hundreds of bars the squeezed ones would likely drop under 1px and dissolve into antialiasing blur. A chart that re-sorts on every step, like 02, also pays a sorting cost that grows with the bar count, and re-sorting only when the keyframe changes keeps that down.

Equal values hand the race order to the sort implementation

The compare function in 02 is only cur[b] - cur[a], so equal values return 0 and the browser's sort decides their order. The seed data has three entries at 34 in the first keyframe, so the starting lanes already depend on this; if your data ties often, add a second key such as the name to the comparison.

A maximum of 0, or a month of all zeros

In 05 a maximum of 0 turns Math.log10(0) into negative infinity and the step collapses into NaN, and in 09 an all-zero data set makes hi − lo equal 0 and breaks the division in the baseline formula. The values in 05 start at 12, so neither failure shows up in the demo, but real reports have empty months, so screen out zeros before they reach the chart. In 09 small changes within ±8 drift toward gray, which blurs the sign for anyone reading by color alone, so the labels carry + and − to keep the sign readable without color.

Digits wider than the bar

In 04 the four-digit "3,297", five characters counting the comma, measures 31.0px and the six-character "15,940" measures 37.9px, so with padding a bar needs 47.0px or 53.9px to hold them. "Shorten bars" scales every value by 0.3 and pushes all seven labels outside, with the shortest bar at 6px. measureText uses whatever font is active at that instant, so with a web font the verdict can change while the font is still loading (this demo stays on plain sans-serif).

Dimmed rows still judge contrast by their original color

Selecting a row in 04 dims the others, yet the label color is chosen from the bar color before dimming. The real contrast of the text on a dimmed row is therefore lower than the computed figure, and semi-transparent bars need their luminance measured again from the color after it blends with the background.

Negatives and thin layers knock a stacked bar out of line

The running sum in 03 assumes positive values, so a negative entry bends a layer's starting height downward and the layers overlap. With many series a layer can get thinner than the 2px gap and turn into nothing but gap, so when a layer is under 2px this chart opens the gap only as far as the layer's own height.

Painting order alone cannot settle every overlap

07 decides front and back by painting cells with a smaller gx + gy first, so columns with different footprints or columns that intersect come out with the wrong occlusion. Shadow length is the height times (Lx, Ly) ÷ Lz, so lowering the light toward 0° sends Lz toward 0 and the shadows stretch without limit, and shadows lie only on the floor rather than climbing the body of another column.

More damping removes the overshoot, more stiffness removes stability

Raising the damping in 01 to about 26 brings the ratio up to 1, and the overshoot this chart is named after disappears, while pushing the stiffness far up can make the numbers jump at a fixed 16ms step. A height that swings below 0 is clipped with Math.max(0, b.x) so the bar does not sink through the floor.

Dots pass straight through one another

08 reserves each dot's seat in its bin the moment the dot spawns, so a late dot falls right past one that is still dropping and lands in its own place. At tens of thousands of dots the resting ones skip the physics but still get redrawn every frame, so drawing is the cost that grows first.

The download holds every chart as a vanilla folder with index.html, style.scss and style.css, next to a react folder that ports the same math, and it unlocks with jm3zs7sw; open it when you want to recreate the breaks above on your own numbers.

Retuning the Damping, Rank Catch-Up and Lens Radius, and Accessibility Through Keys and a Status Line

Variant Changed value What you see
Heavier damping 01 SPRING_DAMPING 12 → 26 The damping ratio goes from 0.46 to 0.997, nearly 1, so the bar aimed at 60 no longer peaks at 70.6; it rises straight and stays within ±0.5 from 592ms instead of 640ms.
Faster rank catch-up 02 RANK_LERP 0.12 → 0.3 Closing a one-lane gap to 10% drops from 19 steps (304ms) to 7 steps (112ms), so bars snap onto their new lane and the moment of overtaking shows less. Both fit inside the 420ms keyframe gap.
Wider lens 06 FISHEYE_RADIUS_PX 80 → 160 Widened bars go from 8 to 14 and squeezed bars from 16 to 32, the widest bar grows from 22.7px to 25.4px, and a whole stretch opens up at once. Bars left at their starting width fall from 36 to 14.

Picking bars without a mouse

All nine canvases carry tabindex="0" and an aria-label, so Tab reaches every chart. ← and → move between bars (02 in current rank order, 07 in cell order), Enter or Space pins the bar or releases it if it is already pinned, and Escape releases the pin. All three keys call preventDefault first, and then the status line at the top right writes the name and value, such as "Item C · 87", or "Item D · 15,940 · outside 4.0:1" in 04.

Toggles that announce their state

Seven charts include a toggle that starts as aria-pressed="false": Pause in 02, Hide series 3 in 03, Shorten bars in 04, Lens off in 06, Stop light in 07, One uniform random in 08 and Flip all in 09. 01 and 05 use two plain push buttons instead.

Hover, pin and focus outlines

A bar that the pointer only hovers gets a dashed [4, 3] outline and a pinned bar gets a solid one, so the two states differ without relying on color. Keyboard focus rings on canvases and buttons come from :focus-visible in style.css. 06 and 07 also listen for pointerleave, and in 06 leaving the chart with nothing pinned sets lensGoal to 0 and the selection to −1, so the lens folds away.

Charts under reduced motion

Each of the nine charts attaches a change listener to matchMedia('(prefers-reduced-motion: reduce)'), so flipping the prefers-reduced-motion setting with the page open takes effect right away. With reduction on, the animation loop stops and the chart jumps to its goal state and draws once, while keys, buttons and clicks still update the result instantly. In 02 the first screen is then the last keyframe and the Pause button only flips its pressed state, leaving the screen as it is. The same media query in style.css also switches off the button background transition.

How Far Does Bar Chart Animation CSS Go, and Where Do You Need a Canvas?

Growing a handful of bars to fixed heights needs nothing beyond a CSS height transition. The nine charts here are canvas and script instead, because each one reads its values to decide the next frame. 02 sorts the values every 16ms to re-rank them, 04 measures text width and luminance to place labels, and 05 derives the tick step from the maximum. The edge remapping in 06, the face normals and light dot products in 07 and the gravity integration in 08 also recompute numbers on every step, which puts them on the script side. Bars come from fillRect and filled paths, digit widths from measureText, and time from requestAnimationFrame.

The round sibling in this data-visualization line is the pie chart set with popping and tilting slices, and lines and bars traced in SVG are in the SVG charts that draw themselves. For canvas charts in general, see the canvas chart set and the advanced chart types, and scenes filled by per-pixel math continue in the canvas scene effects.

Questions to Settle Before Putting a Bar Chart on a Report Screen

Can the spring from 01 drive a horizontal bar graph animation as well?

Yes. springStep handles only three numbers, height, velocity and target, and has no sense of direction, so attached to the bar lengths in 04 it overshoots in the same way. Right now 04 pulls each length 12% of the way to its target per step without overshooting, and placeLabel is called again in step and draw with the length of that moment, len[i], while only report() judges by the target length. With a spring the inside-or-outside verdict could flicker while lengths swing, so passing the target length to the label check as well is the safer route.

Where do my own numbers go?

In 01, replace the target array built by seededValues(seed, BAR_N, 30, 94) with your own eight values. The axis in 01 is fixed at 0 to 100 (AXIS_MAX), so for values above 100 change that constant too, or borrow niceStep and niceMax from 05 to derive the axis end from the data. 09 computes its axis and baseline from the values, so swapping the arrays in sets is enough for the zero line to follow.

How does a screen reader user hear the bar values?

Bars inside a canvas are not separate elements a screen reader can step through, so this demo puts one aria-label on the canvas and reports the chosen bar through the status line. The status line's aria-live stays off during the automatic demo so each demo change goes unannounced, and it switches to polite the moment a person first presses a key, a button or the chart. Report screens where every number has to be checked at once are better served by a table holding the same values beside or below the chart, and this demo does not include one.

Enter the archive password

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