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

Custom Scrollbar CSS — 9 Patterns

custom scrollbar css: For a half second the thumb overshoots the end and springs back under your fingertip.

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

Nine Custom Scrollbar CSS Patterns Ordered by How Far the Thumb Actually Moves

Every demo below drives the same self-made handle — a <div> positioned by transform: translateY() and sized by height, never the browser's own scrollbar box — over an eight-card list at 480×300. The order below is deliberate: item 1 is the plain thumb every other pattern extends, items 2 through 5 each add one reactive value (gloss position, release velocity, overshoot distance, gradient stop), item 6 turns the same math sideways for a horizontal track, items 7 and 8 read time and ratio instead of position, and item 9 stacks three of the earlier values onto a single handle.

01Basic Thumb Whose Thickness Value Fills the Track

A thickness value and a corner-radius value are the only two numbers this pattern needs — THUMB_MIN_PX keeps the handle from shrinking below a draggable size once the list gets long, and syncThumb() recomputes the handle's height and position from the track's own clientHeight on every scroll event.

Basic Thumb Whose Thickness Value Fills the Track
Open in a new tab

01 · Basic Thumb Whose Thickness Value Fills the Track — Measured area 33.433% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

THUMB_MIN_PXKEY_STEP_PXsyncThumb
  function syncThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
  }

  viewport.addEventListener('scroll', syncThumb);

Measured: 33.4% cumulative area, 56.0 average intensity, 23/23 frames moved.

02Thumb Whose Gloss Sweeps on Hover

GLOSS_SWEEP_MS sets how long a light-reflection layer takes to travel the handle's length once, and triggerGloss() is the gate that decides whether that sweep is allowed to fire at all — it refuses to restart mid-sweep and refuses outright once prefers-reduced-motion is set, which is exactly the kind of css scrollbar styling detail that separates a subtle hover cue from a distracting one.

Thumb Whose Gloss Sweeps by on Hover
Open in a new tab

02 · Thumb Whose Gloss Sweeps by on Hover — Measured area 33.401% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GLOSS_SWEEP_MStriggerGlosssyncThumb
  function triggerGloss() {
    if (reduce) return;
    if (sweeping) return;
    sweeping = true;
    gloss.classList.add('is-sweeping');
  }

Measured: 33.4% cumulative area, 43.9 average intensity, 23/23 frames moved.

03Thumb That Coasts Further by an Inertia Value After Release

SCROLLBAR_INERTIA_DECAY is the single number this pattern turns on: the velocity value captured at the instant you release the dragged handle is multiplied by that decay factor every animation frame, so the handle keeps sliding for a few more frames instead of stopping dead the moment your pointer lifts.

Thumb That Coasts Further by an Inertia Value After Release
Open in a new tab

03 · Thumb That Coasts Further by an Inertia Value After Release — Measured area 33.617% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SCROLLBAR_INERTIA_DECAYcoastStepVEL_EPS
  thumb.addEventListener('pointermove', function (e) {
    if (!dragging) return;
    var trackLen = track.clientHeight;
    var thumbLen = thumb.clientHeight;
    var moveRange = Math.max(1, trackLen - thumbLen);
    var scale = maxScroll() / moveRange;
    var frameDeltaPx = (e.clientY - lastMoveY) * scale;
    vel = frameDeltaPx;
    lastMoveY = e.clientY;
    var totalDeltaPx = (e.clientY - dragStartY) * scale;
    viewport.scrollTop = clamp(dragStartScroll + totalDeltaPx, 0, maxScroll());
  });
  function coastStep() {
    if (Math.abs(vel) <= VEL_EPS) { vel = 0; return; }
    vel = vel * SCROLLBAR_INERTIA_DECAY;
    viewport.scrollTop = clamp(viewport.scrollTop + vel, 0, maxScroll());
  }

Measured: 33.6% cumulative area, 57.3 average intensity, 23/23 frames moved.

04Thumb That Overshoots and Springs Back at the End

BOUNDARY_REBOUND_PX sets how far the handle pushes past the track's end before springBack() starts pulling it home — the overshoot value shrinks by SPRING_DECAY every frame until it drops under a small epsilon, at which point the function reports there is nothing left to animate.

Thumb That Overshoots and Springs Back at the End
Open in a new tab

04 · Thumb That Overshoots and Springs Back at the End — Measured area 13.787% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

BOUNDARY_REBOUND_PXspringBackSPRING_DECAY
  thumb.addEventListener('pointermove', function (e) {
    if (!dragging) return;
    var trackLen = track.clientHeight;
    var thumbLen = thumb.clientHeight;
    var moveRange = Math.max(1, trackLen - thumbLen);
    var scale = maxScroll() / moveRange;
    var rawTarget = dragStartScroll + (e.clientY - dragStartY) * scale;
    var ms = maxScroll();
    viewport.scrollTop = clamp(rawTarget, 0, ms);
    if (reduce) {
      overshoot = 0;
    } else if (rawTarget < 0) { overshoot = clamp(rawTarget, -BOUNDARY_REBOUND_PX, 0); }
    else if (rawTarget > ms) { overshoot = clamp(rawTarget - ms, 0, BOUNDARY_REBOUND_PX); }
    else { overshoot = 0; }

Measured: 13.8% cumulative area, 144.1 average intensity, 12/23 frames moved.

05Thumb Whose Gradient Shifts by the Scroll-position Value

applyPositionColor() is the calculation layer between raw scroll position and paint: it takes the same 0-to-1 ratio syncThumb() already computes for the handle's height and feeds it straight into a CSS custom property that both the handle's gradient and the track's glow read from, so a reader can guess roughly where they are in the list from css scrollbar color alone.

Thumb Whose Gradient Shifts by the Scroll-position Value
Open in a new tab

05 · Thumb Whose Gradient Shifts by the Scroll-position Value — Measured area 33.326% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

applyPositionColorsyncThumbTHUMB_MIN_PX
  function syncThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
    applyPositionColor(ratio);
  }

Measured: 33.3% cumulative area, 59.8 average intensity, 23/23 frames moved.

06Horizontal Track Sized by the Card-count Value

CARD_COUNT decides how wide the scrollable row actually is, and syncThumbX() is the same ratio math as item 1 rotated onto the X axis — clientWidth, scrollWidth, and scrollLeft replace their vertical counterparts, and the handle moves by translateX() instead of translateY().

Horizontal Track Sized by the Card-count Value
Open in a new tab

06 · Horizontal Track Sized by the Card-count Value — Measured area 26.656% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

maxScrollXsyncThumbXCARD_COUNT
  function syncThumbX() {
    var trackLen = track.clientWidth;
    var total = viewport.scrollWidth;
    var visible = viewport.clientWidth;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScrollX();
    var ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio;
    thumb.style.width = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateX(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
  }

Measured: 26.7% cumulative area, 55.1 average intensity, 23/23 frames moved.

07Overlay Thumb Whose Opacity Value Fades When Idle

OVERLAY_HIDE_DELAY_MS is how long the handle stays fully visible after the last scroll event, and once that elapses OVERLAY_FADE_MS spreads the opacity drop from 1 to 0 over a fixed span rather than snapping it off — a fresh scroll event resets the elapsed-time value straight back to zero and the opacity value with it.

Overlay Thumb Whose Opacity Value Fades When Idle
Open in a new tab

07 · Overlay Thumb Whose Opacity Value Fades When Idle — Measured area 21.918% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

OVERLAY_HIDE_DELAY_MSOVERLAY_FADE_MSsyncThumb
    if (scrolledFlag) { lastActiveT = t; scrolledFlag = false; }
    var elapsedMs = (t - lastActiveT) * 1000;
    var opacity;
    if (elapsedMs <= OVERLAY_HIDE_DELAY_MS) {
      opacity = 1;
    } else if (reduce) {
      opacity = 0;
    } else {
      opacity = clamp(1 - (elapsedMs - OVERLAY_HIDE_DELAY_MS) / OVERLAY_FADE_MS, 0, 1);
    }
    track.style.opacity = opacity.toFixed(3);

Measured: 21.9% cumulative area, 78.6 average intensity, 13/23 frames moved.

08Scrollbar Whose Thumb-length Value Shows Remaining Content

The same height ratio every other pattern uses to size the handle also drives a plain-language number here: remaining inverts the scroll ratio into a percentage and writes it into a badge, so the handle's shrinking length and the badge's falling number tell the same story two ways at once.

Scrollbar Whose Thumb-length Value Shows Remaining Content
Open in a new tab

08 · Scrollbar Whose Thumb-length Value Shows Remaining Content — Measured area 27.411% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

THUMB_MIN_PXsyncThumbremaining
  function syncThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var lengthRatio = clamp(visible / total, 0, 1);
    var thumbLen = clamp(trackLen * lengthRatio, THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var scrollRatio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * scrollRatio;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(scrollRatio * 100)));
    var remaining = Math.round((1 - scrollRatio) * 100);
    badge.textContent = 'Remaining ' + clamp(remaining, 0, 100) + '%';
  }

Measured: 27.4% cumulative area, 48.0 average intensity, 23/23 frames moved.

09Hero Scrollbar Where Several Values React on One Thumb

paintThumb() is item 1's syncThumb() with one line added — the overshoot value from item 4's spring math is folded straight into the handle's position alongside the scroll ratio — while the same file also keeps item 2's gloss-sweep gate and item 3's inertia-decay constant running underneath, so three separate value chains end up moving the same handle at once.

Hero Scrollbar Where Several Values React on One Thumb
Open in a new tab

09 · Hero Scrollbar Where Several Values React on One Thumb — Measured area 36.332% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GLOSS_SWEEP_MSSCROLLBAR_INERTIA_DECAYBOUNDARY_REBOUND_PX
  function paintThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio + overshoot;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
  }

Measured: 36.3% cumulative area, 66.4 average intensity, 23/23 frames moved.

Where One Value Changes the Thumb — a css scrollbar styling Summary

Three constants carry most of the weight across these nine patterns. SCROLLBAR_INERTIA_DECAY (items 3 and 9) is a per-frame multiplier on velocity, so it only ever slows a moving handle down — it can't push a resting one. BOUNDARY_REBOUND_PX (items 4 and 9) is a fixed pixel distance added once, at the exact frame the scroll ratio hits 0 or 1, then unwound by SPRING_DECAY over the following frames. OVERLAY_HIDE_DELAY_MS (item 7) is the odd one out — it drives opacity, not position, and resets to zero on every scroll event rather than decaying toward one. None of the nine touch scrollbar-width or scrollbar-color; every handle here is a real <div> a script positions, because those two native properties only recolor and resize the browser's own box and can't add gloss, rebound, or a fade timer to it. THUMB_MIN_PX, the one constant every single pattern shares, exists for a narrower reason than any of those three: once a list gets long enough, visible / total in syncThumb() shrinks toward a sliver a pointer could never land on reliably, so THUMB_MIN_PX floors the handle's height regardless of how small the true ratio gets, trading a slightly less accurate length readout for a handle that stays draggable no matter how long the list grows underneath it.

Where Custom Scrollbar CSS Breaks — Reduced Motion, Low-power Devices, and Touch Input

The trap worth knowing before opening the zip is .is-demo: every one of the nine loops through a scripted, seeded scroll cycle by default so the handle looks alive on a page nobody has touched yet, and the very first real pointerdown, keydown, or drag on the handle removes that class and hands control back to the actual scroll position — the instant you interact, the demo trades its rehearsed motion for yours. That handoff matters for testing: judging any of the nine on a fixed load will always catch the rehearsed cycle first, and the only way to see the real drag, coast, or rebound math is to click or tab into the handle and move it, which is exactly what the measurement table further down this page also had to do before it could log a single number. prefers-reduced-motion: reduce cuts deeper: items 2 and 9 skip the gloss sweep outright, item 7 drops straight to zero opacity instead of fading, and the coast and rebound steps in items 3, 4, and 9 still run their math (nothing here breaks reachability) but stop adding the extra frames that make them visible as motion — a screen reader or a keyboard-only reader never notices the difference either way, because role="scrollbar" and aria-valuenow update on the same schedule regardless of what the eye can see. Low-power devices feel item 9 hardest, since it recomputes gloss, inertia, and rebound on the same handle every frame rather than picking one, while a handle sitting on a single constant like item 1 or item 8 costs almost nothing extra beyond the scroll listener every native scrollbar already pays for — recomputing three reactive values on one handle is simply more work per frame than recomputing one, regardless of how fast the device underneath happens to be. Touch input works through the same pointerdown/pointermove/pointerup listeners a mouse uses, so dragging the handle behaves the same on a phone as it does with a cursor and needs no separate touch-specific code path at all, and a short drag on a touch screen still hands vel and overshoot the same numbers a mouse drag would, so items 3, 4, and 9 coast and rebound identically either way, whether the input device is a mouse, a trackpad, or a finger. The wn4b2gqq that unlocks all nine index.html/style.scss pairs sits in this section on purpose — read this far and the constants above stop being numbers on a page and start being values you can actually change.

What Nine Custom Scrollbar Thumbs Actually Moved, Frame by Frame

Item Cumulative area Avg. intensity Moved frames
01 Basic thumb radius 33.4% 56.0 23/23
02 Hover gloss sweep 33.4% 43.9 23/23
03 Drag inertia coast 33.6% 57.3 23/23
04 Boundary elastic overshoot 13.8% 144.1 12/23
05 Gradient position shift 33.3% 59.8 23/23
06 Horizontal track gallery 26.7% 55.1 23/23
07 Floating overlay autohide 21.9% 78.6 13/23
08 Thumb ratio progress badge 27.4% 48.0 23/23
09 Hero scene, three values 36.3% 66.4 23/23

Item 4's average intensity is the highest of the nine at 144.1 while its moved-frame count is the lowest at 12/23 — the rebound only fires at the two frames the scroll ratio actually touches 0 or 1, so the pixels that do change shift hard and fast instead of drifting across most of the cycle the way items 1, 2, 3, 5, 6, 8, and 9 do.

Three Variants Worth Trying, and What Accessibility Still Needs From Them

Variant Value change What shifts
Adjust the thumb inertia-decay value SCROLLBAR_INERTIA_DECAY 0.92 → 0.98 Shortening the value stops the thumb almost the instant you let go; lengthening it lets the thumb keep coasting well after release
Adjust the boundary rebound-distance value BOUNDARY_REBOUND_PX 12 → 32 Shortening the value pushes the thumb only slightly past the end before it returns; lengthening it pushes it far past the end for a bouncier snap back
Adjust the overlay hide-delay value OVERLAY_HIDE_DELAY_MS 600 → 1800 Shortening the value fades the thumb out almost as soon as scrolling stops; lengthening it keeps the thumb visible much longer before it fades

None of the three variants touch the accessibility path above — role="scrollbar", aria-valuenow, and the full keyboard set (arrow keys, Page Up/Down, Home, End) stay wired the same way whether SCROLLBAR_INERTIA_DECAY sits at 0.92 or 0.98, because css scrollbar styling that reacts to a pointer still has to answer to a keyboard: every one of the nine handles removes .is-demo and hands scroll control to the real position the moment an arrow key, not just a click, moves it.

How Is Custom Scrollbar CSS Different From the Browser's Default Scrollbar?

The default scrollbar is a box the rendering engine draws and updates for you; two properties (scrollbar-width, scrollbar-color) are the entire surface a stylesheet gets to touch, and even an engine-prefixed selector aimed at the same box tops out at swapping its color and thickness. The nine patterns above hide that native box (scrollbar-width: none plus the matching engine-prefixed pseudo-selector) and replace it with a real, scripted <div> so gloss, rebound, gradient color, and a fade timer all become things a value can drive — none of which the native box has a hook for. The trade is real: syncThumb() has to run on every scroll event instead of the engine handling it for free, and role="scrollbar" plus aria-valuenow have to be set by hand to keep the result as reachable as the box it replaced.

Questions Worth Answering Before You Wire Up a Custom Scrollbar

Does hiding the native scrollbar break keyboard scrolling?

No — hiding it with scrollbar-width: none and the matching engine-prefixed pseudo-selector only removes the drawn box; the underlying element is still scrollable, and each demo's own keydown listener additionally lets arrow keys, Page Up/Down, Home, and End move the handle directly, on top of whatever the browser already does for the scroll container itself.

Why does item 4's thumb move past the track's actual end?

BOUNDARY_REBOUND_PX adds itself to the handle's position for exactly one calculated moment, the frame the scroll ratio reaches 0 or 1 — it's a deliberate overshoot, not a bug, and springBack() starts pulling that extra distance back to zero on the very next frame.

Can these nine be combined on a single page, or is each self-contained?

Each pattern is its own index.html/style.scss pair with its own constants and its own demoTick() loop, so any one of the nine drops into an existing page without pulling the other eight in — item 9 is the only file that already combines three value chains (gloss, inertia, rebound) on purpose, and it does so inside one self-contained file rather than importing from the other eight.

MDN's guide to scrollbar-color documents the two native properties these nine replace, and prefers-reduced-motion covers the media query every demo above checks before its first frame. For the same inertia-decay idea applied to full-page momentum instead of a single handle, Scroll Inertia CSS — 9 Value Patterns is the closest sibling in structure; CSS Scroll Animation: 9 View-Timeline Examples reads scroll position through a declarative timeline instead of a scroll listener, worth comparing against item 5's manual ratio math here; and 9 Reading Progress Bar CSS Patterns, No Library is the closest cousin in purpose — both sets turn a scroll ratio into something visible, but a reading-progress bar sits outside the scrollable element while every handle above replaces the element's own scrollbar.

Enter the archive password

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