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

CSS Waveform: 9 Peak Columns and Trim Pickers

A css waveform starts with squeezing thousands of samples into pixel columns — scene 01's min/max buckets and scene 05's zero-crossing snap do the

Play, Zoom and Trim the Nine Audio Waveforms Right Here

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

CSS Waveform in 9 Scenes — Bucketing, Envelope Following and Zero-Crossing Snap

A waveform is the picture a music app or a voice recorder shows for a sound: bars that swing above and below a center line, taller where the sound is louder. None of the nine scenes here makes a sound. Each signal is an array of numbers built from a seeded linear congruential generator lcg, an integer hash hash1, and summed sines, and none of the nine files contains AudioContext or getUserMedia, so no microphone permission prompt shows up. The same seed gives the same waveform, so calling the same function from the zip reproduces the figures quoted below.

The drawing happens on a <canvas> 2D context, not in CSS keyframes. Scene 01 alone has to fold 43,200 visible samples (out of 86,400) into 160 columns, and only a script that counts can tell which sample sets the tip of which column. CSS covers the outer frame: the stage ratio aspect-ratio: 480 / 300, a touch-action value that differs from scene to scene, the :focus-visible outline, and the reduced-motion media query.

The nine scenes share one boot() and differ only in their makeXxxScene function. step() runs at a fixed FIXED_DT of 16ms while draw() runs once per frame, and the catch-up time per frame is capped at 160ms, so a tab that sat in the background for a while advances at most 10 steps at once when it comes back. After 220 steps, about 3.5 seconds, with no input, a fake pointer appears and plays a demo script, and the script stops the moment a person touches the scene.

It saves time to pick a starting point by use case. If you are shaping a css audio waveform animation for a player screen, open 01 for a timeline preview, 03 for playback progress, and 02 for a voice memo list. A trim screen for audio or video sits closest to 04 and 05, an instrument tuner or a signal dashboard to 06, the hero of a music event landing page to 07, a player's level meter to 08, and a meeting recorder to 09.

Reading the Code Behind Each CSS Waveform Animation Technique

The phrase css waveform animation tends to bring to mind bars that bob up and down by themselves as decoration, but the snippets below show how an array of numbers gets divided into screen columns. Each snippet is a run of consecutive lines lifted from the <script> in that scene's index.html. The shared helper scanRange(sig, a, b), which 01, 02, 03, 04, and 07 call, walks from sample a up to just before b and leaves the smallest and largest values in rangeLo and rangeHi, so wherever rangeLo appears in a snippet, it holds that result.

01A Basic Waveform Where Tens of Thousands of Samples Shrink Into Min/Max Buckets and Become Pixel Columns

bucketColumns divides the 480 width by a BUCKET_PX of 3 into 160 columns and spreads the 43,200 visible samples evenly across them, so each column stands for only the max and min of 270 samples. An average would blend a short spike into its neighbors until it disappeared, while max and min keep that one sample at the column tip, and in quiet stretches where a column would be shorter than 1px, PEAK_FLOOR keeps it 1px above and below the center so the line does not break. Move the lens column with the pointer or ←/→ and the status pill reports that column's max and min.

A Basic Waveform Where Tens of Thousands of Samples Shrink Into Min/Max Buckets and Become Pixel Columns
Open in a new tab

01 · A Basic Waveform Where Tens of Thousands of Samples Shrink Into Min/Max Buckets and Become Pixel Columns — Measured area 38.376% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BUCKET_PXSAMPLE_NBAR_GAPPEAK_FLOOR
function bucketColumns(sig, start, view, bucketPx) {
    const n = Math.floor(W / bucketPx);
    const per = view / n;
    const lo = [];
    const hi = [];
    for (let c = 0; c < n; c += 1) {
        scanRange(sig, start + c * per, start + (c + 1) * per);
        lo.push(rangeLo);
        hi.push(rangeHi);
    }
    return { lo, hi };
}

02A Waveform Whose Fast-Attack, Slow-Release Follower Fills a Smooth Loudness Outline

rmsAt takes the root mean square of the RMS_WIN of 160 samples ending at each column, and followEnvelope chases that value by adding a fraction of the gap each column: ATTACK_K (0.5) on the way up and RELEASE_K (0.06) on the way down. On a step from 0 to 1 the outline reaches 90% within 3 columns, while after a drop from 1 to 0 it takes 38 columns to sink below 10%, hanging on like the tail after a drum hit. Following starts 30 columns (WARM_COLS) before the first visible one, so the outline at the left edge does not climb up from 0.

A Waveform Whose Fast-Attack, Slow-Release Follower Fills a Smooth Loudness Outline
Open in a new tab

02 · A Waveform Whose Fast-Attack, Slow-Release Follower Fills a Smooth Loudness Outline — Measured area 29.142% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ATTACK_KRELEASE_KRMS_WINFILL_ALPHA
function rmsAt(sig, end, win) {
    const to = clamp(Math.floor(end), 1, sig.length);
    const from = Math.max(0, to - win);
    let sum = 0;
    for (let i = from; i < to; i += 1) {
        sum += sig[i] * sig[i];
    }
    return Math.sqrt(sum / Math.max(1, to - from));
}
// Exponential smoothing with separate attack and release factors — quick on the way up, slow on the way down
function followEnvelope(values, attackK, releaseK) {
    const out = [];
    let e = 0;
    for (let i = 0; i < values.length; i += 1) {
        const v = values[i];
        e += (v > e ? attackK : releaseK) * (v - e);
        out.push(e);
    }
    return out;
}

03A Waveform Whose Playhead Splits a Column at Half-Pixel Precision to Recolor Played and Unplayed Parts

halfPixel snaps the playhead to a 0.5px grid with Math.round(px * 2) / 2, and when that position lands inside the 4px body of a column, the same rectangle is painted twice through two clip() regions, pink (PLAYED_HUE 340) on the left and faint ink (UNPLAYED_ALPHA 0.28) on the right. Because the boundary passes through a column instead of hopping to the gap between columns, the playhead reads finer than the 6px column pitch. The canvas is a role="slider" reporting 0 to 6 seconds, ←/→ moves it 0.5 seconds per press, and dragging makes it ease toward the target by a SCRUB_EASE of 0.25 per step.

A Waveform Whose Playhead Splits a Column at Half-Pixel Precision to Recolor Played and Unplayed Parts
Open in a new tab

03 · A Waveform Whose Playhead Splits a Column at Half-Pixel Precision to Recolor Played and Unplayed Parts — Measured area 13.158% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PLAYHEAD_WPLAYED_HUEUNPLAYED_ALPHASCRUB_EASE
                if (x1 <= ph) {
                    ctx.fillStyle = played;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                }
                else if (x0 >= ph) {
                    ctx.fillStyle = rest;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                }
                else {
                    // The column under the playhead — split with two clip regions, strong on the left, faint on the right
                    ctx.save();
                    ctx.beginPath();
                    ctx.rect(x0, 0, ph - x0, H);
                    ctx.clip();
                    ctx.fillStyle = played;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                    ctx.restore();
                    ctx.save();
                    ctx.beginPath();
                    ctx.rect(ph, 0, x1 - ph, H);
                    ctx.clip();
                    ctx.fillStyle = rest;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                    ctx.restore();
                }

04A Waveform Where Wheel Zoom Keeps the Moment Under the Pointer in Place and Rebuilds the Buckets

On each wheel notch, notch first records the sample index under the pointer as anchorT and changes only the target zoom by a ZOOM_STEP of 1.5; as the zoom closes in through z * Math.pow(r, 0.2), viewStartFor solves for the view start backward as anchorT - anchorPx / W * span, pinning the same sample to the same screen x. The range is rebucketed at the new zoom on every draw, and once there are fewer than 1.6 samples per pixel the columns give way to sample dots joined by lines. Wheel input that changes nothing because the zoom already sits at 1 or 96 is left alone, so the page scrolls instead.

A Waveform Where Wheel Zoom Keeps the Moment Under the Pointer in Place and Rebuilds the Buckets
Open in a new tab

04 · A Waveform Where Wheel Zoom Keeps the Moment Under the Pointer in Place and Rebuilds the Buckets — Measured area 35.085% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

ZOOM_MINZOOM_MAXZOOM_STEPVIEW_START
    function recompute() {
        span = SAMPLE_N / z;
        start = viewStartFor(anchorT, anchorPx, span);
    }
    // One wheel notch — anchor the sample under the pointer and change only the target zoom
    function notch(dir, px) {
        const next = clamp(zt * (dir > 0 ? ZOOM_STEP : 1 / ZOOM_STEP), ZOOM_MIN, ZOOM_MAX);
        if (next === zt) {
            return false;
        }
        anchorT = start + px / W * span;
        anchorPx = px;
        zt = next;
        if (h.reduce) {
            z = zt;
            recompute();
        }
        return true;
    }

05A Range Picker Whose Trim Handles Get Pulled to the Nearest Zero Crossing

zeroCrossings finds each spot where two neighboring samples change sign and interpolates linearly to the x where the value hits 0, down to fractions of a pixel, and when a handle is released, snapTarget picks the nearest crossing within a SNAP_RADIUS of 6px while SNAP_EASE eases the handle there by 0.25 of the remaining distance per step. Because the signal is 0 at a trimmed edge, joining the parts before and after a cut leaves no abrupt step, and when no crossing lies within the radius the handle stays where it was dropped and the status pill switches to No zero crossing within the radius. The two handles are <div> elements with role="slider", so Tab reaches them, and ←/→ jumps straight to the next or previous zero crossing.

A Range Picker Whose Trim Handles Get Pulled to the Nearest Zero Crossing
Open in a new tab

05 · A Range Picker Whose Trim Handles Get Pulled to the Nearest Zero Crossing — Measured area 17.933% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SNAP_RADIUSHANDLE_WMIN_SPANSNAP_EASE
function zeroCrossings(sig) {
    const xs = [];
    for (let i = 0; i < sig.length - 1; i += 1) {
        const a = sig[i];
        const b = sig[i + 1];
        if ((a < 0 && b >= 0) || (a >= 0 && b < 0)) {
            xs.push((i + a / (a - b)) / (sig.length - 1) * W);
        }
    }
    return xs;
}
// From the drop point x to the nearest zero crossing within the radius — stay put if there is none
function snapTarget(x, crossings, radius) {
    let best = x;
    let bestD = radius + 0.0001;
    for (let i = 0; i < crossings.length; i += 1) {
        const d = Math.abs(crossings[i] - x);
        if (d < bestD) {
            bestD = d;
            best = crossings[i];
        }
    }
    return best;
}

06A Rolling Oscilloscope Whose Rising-Edge Trigger Makes the Trace Hold Still

findTrigger searches a 2,048-sample buffer for the point where the signal crosses the TRIG_LEVEL of 0.15 going up, and the upper panel draws 480 samples from there so the moving signal starts at the same spot on each frame. The input carries a fast ripple, 0.09 * Math.sin(n * 0.83), that can push it over the level twice in one cycle, and a HOLDOFF of 40 throws away any next edge within 40 samples of the accepted one. The lower panel is a free run with no trigger that slides 9 samples per step, so watching the two together shows exactly what the trigger contributes.

A Rolling Oscilloscope Whose Rising-Edge Trigger Makes the Trace Hold Still
Open in a new tab

06 · A Rolling Oscilloscope Whose Rising-Edge Trigger Makes the Trace Hold Still — Measured area 32.302% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRIG_LEVELHOLDOFFTRACE_GLOWTIME_DIV
// Input signal — an asymmetric wave with slowly drifting frequency plus a fast ripple. The sample index alone sets the value
function scopeSample(n) {
    const phase = PI2 * BASE_FREQ * (n + 285.7 * (1 - Math.cos(0.0007 * n)));
    return 0.62 * Math.sin(phase) + 0.28 * Math.sin(2 * phase + 0.9) + 0.09 * Math.sin(n * 0.83) + (hash1(n, 7) - 0.5) * 0.05;
}
// Find where recent samples cross the level going up, and drop the next edge inside HOLDOFF
function findTrigger(buf, level, holdoff, show) {
    let kept = -1;
    let found = -1;
    for (let j = 1; j + show <= buf.length; j += 1) {
        if (buf[j - 1] < level && buf[j] >= level) {
            if (kept < 0 || j - kept >= holdoff) {
                kept = j;
                found = j;
            }
        }
    }
    return found;
}

07A Polar Scene Where the Waveform Wraps Around a Ring and Lights Up Behind a Turning Playhead Needle

The circle is split into BAR_COUNT 96 slots, each slot draws the max of its 450 samples outward and the min inward, and lengths are compressed by raising the amplitude to the 0.7 power so bars for quiet passages still stand out. The needle turns at a SWEEP_SPEED of 2.2 radians per second, about 2.86 seconds per lap, and applying Math.exp(-behind * AFTERGLOW) to the angle turned since the needle passed keeps only the bars right behind it glowing pink. The canvas is a slider reporting 0 to 360 degrees, and when you press and turn, atan2 around the circle's center makes the needle follow your hand.

A Polar Scene Where the Waveform Wraps Around a Ring and Lights Up Behind a Turning Playhead Needle
Open in a new tab

07 · A Polar Scene Where the Waveform Wraps Around a Ring and Lights Up Behind a Turning Playhead Needle — Measured area 22.116% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RING_RBAR_COUNTSWEEP_SPEEDAFTERGLOW
            ctx.lineWidth = PI2 * RING_R / BAR_COUNT * 0.74;
            const cur = Math.floor(theta / PI2 * BAR_COUNT) % BAR_COUNT;
            for (let i = 0; i < BAR_COUNT; i += 1) {
                const a = (i + 0.5) / BAR_COUNT * PI2;
                // How far the needle has turned past this bar — the closer, the brighter
                const behind = ((theta - a) % PI2 + PI2) % PI2;
                const glow = Math.exp(-behind * AFTERGLOW);
                const outer = RING_R + Math.pow(Math.max(bars.hi[i], 0.03), 0.7) * BAR_LEN;
                const inner = RING_R - Math.pow(Math.max(-bars.lo[i], 0.03), 0.7) * BAR_LEN * 0.8;
                const s = Math.sin(a - Math.PI / 2);
                const c = Math.cos(a - Math.PI / 2);
                ctx.strokeStyle = rgba(mix(LILAC, PINK, glow), 0.42 + 0.58 * glow);
                ctx.beginPath();
                ctx.moveTo(CX + c * inner, CY + s * inner);
                ctx.lineTo(CX + c * outer, CY + s * outer);
                ctx.stroke();
            }

08An Equalizer Whose Bars Jump at Once While the Peak Caps Linger and Then Fall Like Gravity

For each of the 24 bands, stepBand lifts the bar straight to the target when the target is higher and otherwise lowers it by only 0.12 of the gap (BAR_FALL_K), so the bar needs 19 frames to drop below 10%. The peak cap holds for 28 frames (HOLD_FRAMES) each time the bar reaches it, then falls faster and faster as CAP_FALL_G 0.0006 is added to its speed per frame, and if it drops below the bar, it snaps back to the bar height with zero speed. Pressing a band pushes it up to the pressed height, and picking a band with ←/→ and then pressing Enter or Space raises it to 0.95.

An Equalizer Whose Bars Jump at Once While the Peak Caps Linger and Then Fall Like Gravity
Open in a new tab

08 · An Equalizer Whose Bars Jump at Once While the Peak Caps Linger and Then Fall Like Gravity — Measured area 18.564% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAND_NHOLD_FRAMESCAP_FALL_GBAR_FALL_K
function stepBand(s, target, fallK, holdFrames, gravity) {
    if (target > s.bar) {
        s.bar = target;
    }
    else {
        s.bar += (target - s.bar) * fallK;
    }
    if (s.bar >= s.cap) {
        s.cap = s.bar;
        s.hold = holdFrames;
        s.vel = 0;
    }
    else if (s.hold > 0) {
        s.hold -= 1;
    }
    else {
        s.vel += gravity;
        s.cap -= s.vel;
        if (s.cap < s.bar) {
            s.cap = s.bar;
            s.vel = 0;
        }
    }
}

09A Waveform Where Live Recording Appends on the Right and Auto Gain Lifts Quiet Sounds

Each step, append adds one column built from the max and min of 80 samples at the right end and drops the leftmost one, so 240 columns 2px wide cross the screen in about 3.8 seconds. In the same pass, peakHold keeps a peak record that decays by a factor of 0.996 per step, and stepGain moves the gain toward GAIN_TARGET / peakHold, clamped between 0.5 and 8, at a GAIN_RISE rate of 0.012 going up and a GAIN_FALL rate of 0.35 going down. Press the canvas, or press Enter or Space, and a loud clap-like burst comes in, so you can watch the gain fall back quickly.

A Waveform Where Live Recording Appends on the Right and Auto Gain Lifts Quiet Sounds
Open in a new tab

09 · A Waveform Where Live Recording Appends on the Right and Auto Gain Lifts Quiet Sounds — Measured area 33.41% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAIN_TARGETGAIN_RISEGAIN_FALLSCROLL_PX
    function append() {
        const lv = level();
        let lo = 1e9;
        let hi = -1e9;
        for (let i = 0; i < PER_STEP; i += 1) {
            const ph = (n + i) * 0.19;
            const v = lv * (Math.sin(ph) * 0.8 + Math.sin(ph * 2.3 + 0.5) * 0.25) + (hash1(n + i, 21) - 0.5) * 0.04;
            if (v < lo) {
                lo = v;
            }
            if (v > hi) {
                hi = v;
            }
        }
        n += PER_STEP;
        const stepPeak = Math.max(Math.abs(lo), Math.abs(hi));
        peakHold = Math.max(stepPeak, peakHold * PEAK_DECAY);
        gain = stepGain(gain, peakHold, GAIN_TARGET, GAIN_RISE, GAIN_FALL);
        rawLo.shift();
        rawHi.shift();
        gains.shift();
        rawLo.push(lo);
        rawHi.push(hi);
        gains.push(gain);
    }

Three Constants That Reshape the Waveform — Bucket Width, Release Factor, Snap Radius

Before the constants, here is how much each scene changed on screen when all nine were last rendered in a browser and 24 frames were captured. Covered area is the share of pixels that changed at least once, and moving frames counts how many of the 23 comparisons between neighboring frames caught a change.

Scene Covered area Mean intensity Moving frames
01 Min/max columns 38.4% 67.9 23/23
02 RMS envelope 29.1% 78.5 23/23
03 Playhead split 13.2% 99.5 23/23
04 Wheel zoom anchor 35.1% 80.8 16/23
05 Zero-crossing trim 17.9% 78.5 23/23
06 Rising-edge trigger 32.3% 69.8 23/23
07 Polar ring 22.1% 59.6 23/23
08 Peak-hold equalizer 18.6% 133.9 23/23
09 Recording auto gain 33.4% 110.4 23/23

Scene 03 covers the least area, 13.2%, since only the columns behind the playhead change color, and in scene 04 seven of the 23 comparisons found the screen unchanged. The figures in the subsections below come from a different source: each scene's functions called directly in Node.

Widening BUCKET_PX from 3 to 8 cuts the columns from 160 to 60

Each column then covers 720 samples instead of 270. Measured against an outline built from 480 columns of 1px each, the mean error grows from 0.030 to 0.083 and the largest error from 0.62 to 0.82, so a thin sound sitting right next to a loud one gets swallowed by that column. Bars look bolder while detail drops out, and for an editing screen where detail matters, 3 is the better choice.

Raising RELEASE_K to 0.25 shortens the tail from 38 columns to 9

On the synthetic signal, the mean gap between the instant RMS and the outline narrows from 0.026 to 0.008, so the outline hugs the instant value. The factor is applied per column, which ties its time constant to the 60 samples each column holds; widen the visible range so each column holds more samples, and the same 0.06 feels like a longer tail.

A SNAP_RADIUS of 40 catches each drop but moves the handle up to 11.7px

The signal in 05 has 23 zero crossings spaced 20.2px apart on average, 13.6 at the tightest and 23.5 at the widest. Dropping a handle at each pixel from 10px to 470px, a radius of 6 pulls in 60% of the drops with a mean move of 1.8px (max 6.0px), while a radius of 40 pulls in 100% with a mean move of 5.1px and a max of 11.7px. The demo script's second drop at 329px stays put at radius 6, because the nearest crossings, 317.2 and 340.7, are 11.8px and 11.7px away.

Lifting a quiet input takes 2.8 seconds, while pulling the gain back after a clap takes 11 steps

In 09, an input peak of 0.12 gives a target gain of 0.8 / 0.12 = 6.67, and a GAIN_RISE of 0.012 needs 178 steps, about 2.8 seconds, to reach 90% of it. A clap peaking at 0.95 runs the other way: with a GAIN_FALL of 0.35 the gain drops from 6.5 to below 0.9 in 11 steps. Slow going up and quick coming down is the attack and release idea from 02, turned around.

When the Waveform Breaks — Wide Buckets, Edge Zoom, a Level Above the Signal

Each case below is a spot where a waveform starts to look wrong, pinned down with numbers by calling the source functions in Node. Nothing in this section was timed in a browser.

Without off-screen warm-up, the left-edge outline starts at half height

In 02, followEnvelope starts from let e = 0 and carries each column's value into the next. With WARM_COLS running 30 columns off screen first, the outline of the first visible column is 0.242; take the warm-up out and it starts near half that, at 0.116, and the gap reaches 0.126 within the first 40 columns, drawing a slope on the left edge that the sound does not have.

Zooming near the screen edge shifts the sample under the pointer

Zoom with the pointer at 330px or 460px, and the anchor holds exactly that spot at zoom 4, 16, and 96. Put sample 300 at 20px on screen and zoom, though, and the view start cannot drop below 0, so the anchor's screen position moves 3.3 → 13.3 → 20 → 20px at zoom 1, 4, 16, and 96. That shift over the first few steps comes from the clamp in viewStartFor, and it is the price of not showing empty space past either end.

A playhead that lands in a gap has nothing to split

Columns in 03 have a 4px body and a 2px gap. Of the 960 positions available at 0.5px steps, 58.3% fall inside a body, where two clip regions split the column, and the rest land in a gap or on a boundary and get a single color. The coordinates are logical, so on a screen with a device pixel ratio of 1 a 0.5px boundary blurs from antialiasing, and at a ratio of 2 it lines up with device pixels.

The two trim handles cannot come closer than 24px

The MIN_SPAN of 24px in 05 is applied once more through limit() after snapping finishes. It is wider than the 20px average spacing between zero crossings, so a very short selection with one handle on each of two neighboring crossings needs this value lowered first.

A holdoff of 40 still leaves some trigger bounce

Running 06 for 400 steps and counting how often the trigger point jumped back within 60 samples gives 22 jumps at a HOLDOFF of 0 and 9 at 40. The first sample of the triggered window stays inside a 0.143 range, while the first sample of the free-run window swings across 1.676, and that gap is why the upper panel looks so much calmer than the lower one.

A level above the signal peak sends the trigger back to the free-run window

The input in 06 peaks at 0.73, so as long as the level stays within the LEVEL_LIMIT of 0.7, a trigger is found on each of 300 steps, at +0.7 and at −0.7 alike. Lift the limit and set the level to 0.99, and all 300 searches come up empty, so the upper panel draws the last screen of the buffer and ends up looking like the free run below it.

Right after a clap, a few columns print as pink clipping

While the gain in 09 is still high, the first few columns come out above 1 after the gain is applied, print in pink and switch the status pill to Clipping. In the opposite direction, a long silence keeps shrinking peakHold until the gain climbs to its cap of 8, and the noise floor is then amplified eightfold along with everything else.

Touch cannot scroll the page over scene 07

The whole canvas of 07 is touch-action: none, so swiping over the ring does not move the page. Scene 04 uses pan-y, handing vertical scrolling to the browser and keeping horizontal drags for itself; 06 is the reverse with pan-x; and 05 sets none only on its handle elements. If 07 sits in the middle of a long article, leaving some scroll room around the ring is the safer layout.

Peak caps take the same time to fall on a slow device

Bars and caps in 08 change only in fixed 16ms steps, so the 85 frames a cap needs from the top to the floor, hold included, stay the same at any refresh rate. A tab that returns from the background catches up at most 10 steps at once, so a cap that should have fallen in the meantime keeps descending rather than vanishing in one jump.

To try the constants above on your own copy, use the files in the zip, which holds each scene's index.html and style.scss plus a React version and is unlocked with the password k2eeh529.

Three Variants and How the Scenes Handle Accessibility — Keys, Live Text, Reduced Motion

Name Value Feel
Change the bucket width 01 BUCKET_PX 3 → 8 Columns drop from 160 to 60 and the mean error rises from 0.030 to 0.083, so the bars get bolder while thin sounds sink into their loud neighbors.
Change the release factor 02 RELEASE_K 0.06 → 0.25 A tail that took 38 columns to sink below 10% now takes 9, and the outline tracks closely behind the instant value.
Change the snap radius 05 SNAP_RADIUS 6 → 40 The handle goes from catching 60% of drops to 100%, at the cost of moving 5.1px on average and up to 11.7px away from where the hand let go.

All three rows were counted by calling the functions directly, the same way as in the constants section.

The nine scenes can all be operated from the keyboard. Each canvas has tabindex="0" and the two handles in 05 carry their own, while preventDefault() runs only for the keys a scene handles (arrows, Home, End, +/-, Enter, Space) and, in 04, only for wheel input that actually changes the zoom. Three scenes have a slider role: the 03 canvas updates aria-valuetext in the form 3.0 / 6.0 s and the 07 canvas in the form 77 deg, each every 30 steps or right after input, and the 05 handles write their sample index from 0 to 1919 only when it changes.

The status pill <p> starts as aria-live="polite" in the HTML, but while the demo runs on its own it switches to off, which keeps a screen reader from narrating the fake pointer. When a person touches a scene, or the reduced-motion setting is on, it goes back to polite, and updates bunched within 250ms are skipped. The focus outline lives in the style.css of each of the nine scenes as :focus-visible.

With prefers-reduced-motion on, no requestAnimationFrame loop starts. A scene advances 40 steps up front to build its first frame, and each press of Next moment, which takes the pause button's place, advances 56 steps, or 900ms worth, then draws once. Input lands without interpolation: 03 moves the playhead to the pressed spot, 04 jumps to the target zoom, 05 sets the handle on its snap target, 08 stands the bar and its peak cap at the pressed height, and 09 appends thirty columns in one go after a clap.

Can Audio Waveform CSS Handle This on Its Own?

For decoration, such as a few bars bobbing up and down with no beat behind them, CSS alone does the job. The work in this article, picking column tips out of tens of thousands of samples, following an outline and landing a cut on zero, comes down to counting values, so it belongs in a script. Here CSS handles little more than the stage size: .wf is width: min(92vw, calc(160vh - 12.8vw)) with aspect-ratio: 480 / 300, and the coordinates are 480×300 logical ones, so the column count holds steady when the window resizes.

Drawing real sound received through the browser audio API is covered in Audio Visualizer JavaScript: 9 Parts, No Library, and reading this next to Custom Audio Player CSS — 9 Computed UI Patterns, with its play button and progress bar, gives a feel for where the playhead from 03 could sit. The two-handle setup in 05 resembles the two-thumb sliders in Dual Range Slider UI — 9 Two-Handle Patterns, and if the curves drawn from summed sines are what caught your eye, Wave Interference Simulation: 9 Canvas Scenes picks up from there. The drawing approach was checked against the requestAnimationFrame and clip() documentation.

Questions to Settle Before Reusing These Waveform Scenes

Where do I change things to show the waveform of a real recording?

In 01, 02, 03, 04, and 07, the synth call on the first line of the scene function, which takes SAMPLE_N and a seed, builds the number array, so put your own sample array in its place. Scene 01 multiplies values by a HALF_Y of 84 to set heights, so the array should be scaled to the −1 to 1 range for the columns to stay inside the stage. Reading a file and decoding it into samples is not part of these demos.

Will a css audio waveform animation slow down with millions of samples?

Scene 01's bucketColumns scans the 43,200 visible samples once on each draw, so the work grows as the visible range gets longer. Scene 04 buckets its 120-slot overview strip once, when the scene is created, and does not recount it; for a long track, keeping prebucketed arrays like that for each zoom level is a good place to start.

Does the column count change when the screen width changes?

It does not. fit() multiplies the canvas's actual width by devicePixelRatio to resize only the backing buffer, then stretches the 480×300 coordinates with setTransform. At 340px and below, the caption, button and status pill text shrink to 10px while the canvas ratio stays the same, and the render check found no phone-width overflow in any of the nine scenes.

Enter the archive password

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