I am trying to create a custom scrollbar using a div element. The scrollbar itself works well, but I noticed an issue:
When I place my mouse cursor over the scrollbar track or the thumb (as shown in the image below, the green and gray areas), scrolling the mouse wheel does not properly scroll the content.
Here is my code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Floating Scrollbar</title>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
main {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
.scroll-content {
width: 100%;
height: 100%;
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
}
.scroll-content::-webkit-scrollbar {
display: none; /* Chrome */
}
.custom-scrollbar {
position: absolute;
top: 0;
right: 30px;
width: 50px;
height: 100%;
background-color: #3CB521;
}
.scroll-thumb {
width: 100%;
height: 50px;
background: gray;
position: absolute;
top: 0;
transition: background 0.2s;
padding-right: 30px;
}
.scroll-thumb::after {
content: "";
position: absolute;
top: 0;
right: -30px;
width: 30px;
height: 100%;
background: transparent;
}
.scroll-thumb:hover, .scroll-thumb:active {
background: black;
}
section {
width: 100%;
height: 100vh;
display: grid;
place-items: center;
font-size: 8em;
}
section:nth-child(1) {
background: #fff2cc;
}
section:nth-child(2) {
background: #e2f0d9;
}
section:nth-child(3) {
background: #deebf7;
}
section:nth-child(4) {
background: #fbe5d6;
}
</style>
</head>
<body>
<main>
<div class="scroll-content">
<section>1</section>
<section>2</section>
<section>3</section>
<section>4</section>
</div>
<div class="custom-scrollbar">
<div class="scroll-thumb"></div>
</div>
</main>
<script>
const content = document.querySelector('.scroll-content');
const scrollbar = document.querySelector('.custom-scrollbar');
const thumb = document.querySelector('.scroll-thumb');
function updateThumbHeight() {
let contentHeight = content.scrollHeight;
let visibleHeight = content.clientHeight;
let thumbHeight = Math.max((visibleHeight / contentHeight) * visibleHeight, 10);
thumb.style.height = `${thumbHeight}px`;
}
function syncThumbPosition() {
let scrollRatio = content.scrollTop / (content.scrollHeight - content.clientHeight);
let maxThumbTop = scrollbar.clientHeight - thumb.clientHeight;
thumb.style.top = `${scrollRatio * maxThumbTop}px`;
}
content.addEventListener('scroll', () => {
requestAnimationFrame(syncThumbPosition);
sessionStorage.setItem("scrollPosition", content.scrollTop);
}, {passive: true});
window.addEventListener('load', () => {
updateThumbHeight();
let savedScrollPosition = sessionStorage.getItem("scrollPosition");
if (savedScrollPosition !== null) {
content.scrollTop = parseInt(savedScrollPosition, 10);
syncThumbPosition();
}
});
let isDragging = false, startY, startScrollTop;
thumb.addEventListener('mousedown', (e) => {
isDragging = true;
startY = e.clientY;
startScrollTop = content.scrollTop;
document.body.style.userSelect = 'none';
});
document.addEventListener('mouseup', () => {
isDragging = false;
document.body.style.userSelect = '';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
let deltaY = e.clientY - startY;
let scrollRatio = (content.scrollHeight - content.clientHeight) / (scrollbar.clientHeight - thumb.clientHeight);
content.scrollTop = startScrollTop + deltaY * scrollRatio;
});
window.addEventListener('resize', () => {
updateThumbHeight();
syncThumbPosition();
});
if ('scrollRestoration' in history) {
history.scrollRestoration = "manual";
}
</script>
</body>
</html>
Issue:
When I hover over the custom scrollbar track (green area) or the scrollbar thumb (gray area), scrolling with the mouse wheel does not work.
Scrolling works fine when the mouse is outside the scrollbar area.
Expected Behavior:
In order to register the "wheel"
event while the mouse is over the custom scrollbar, that element has to be a child of that scrollable parent.
Scrollbar with position absolute
is of no good since it will scroll along with the scrollable parent's content.
Use position: sticky;
instead!
The thing with sticky
is that it'll make space for itself among its siblings. What trick to use in order to collapse the sibling elements? float: right;
. Remember the good old float
? Here's resurrected and put to good use. Also very important to notice is that the .scrollbar
has to be prepended in the scrollable element (as a first child).
Also, the .scrollbar
's width has to be 0px
in order not to infer with the float
and the positioning (and widths) of sibling elements. Set the desired width to the inner .track
instead.
The minimal HTML and CSS:
* { margin: 0; box-sizing: border-box; }
body { font: 1rem/1.4 system-ui, sans-serif; }
.content {
position: relative;
height: 100dvh;
overflow-y: scroll;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
section {
height: 100dvh;
&:nth-child(odd) { background: #ddd; }
&:nth-child(even) { background: #ace; }
}
}
.scrollbar {
position: sticky;
top: 0;
float: right;
right: 20px;
width: 0px; /* To not get in the way of float space */
height: 100%;
touch-action: none;
.track {
position: absolute;
right: 0;
height: 100%;
width: 40px;
background-color: #333;
}
.thumb {
height: 50px; /* dummy height */
width: 100%;
background: #888;
position: absolute;
top: 0;
}
}
<main class="content">
<div class="scrollbar"><div class="track"><div class="thumb"></div></div></div>
<section>Section 1</section>
<section>Section 2</section>
<section>Section 3</section>
<section>Section 4</section>
</main>
In order to support touch, pointer, wheel, instead of mouse/touch, make use of Pointer events.
The scrollable container listens to "wheel"
event, whilst the .scrollbar
has to listen to "pointerdown"
and "pointermove"
events. In order to notify the browser that the event should be attached to the scrollbar even if the pointer (during the drag) no longer hovers the thumb — use [set/has]PointerCapture.
Also a really important notice, since we make use of pointers, we need to use touch-action: none;
on the scrollbar element in order to claim and disable native touch interactions from the browser.
Additionally:
forEach
your desired elementsFinal result:
// DOM utility functions:
const el = (sel, par = document) => par.querySelector(sel);
const els = (sel, par = document) => par.querySelectorAll(sel);
const elNew = (tag, prop) => Object.assign(document.createElement(tag), prop);
const customScrollbar = (content) => {
const scrollbar = elNew("div", { className: "scrollbar" });
const track = elNew("div", { className: "track" });
const thumb = elNew("div", { className: "thumb" });
let startY = 0;
let startScrollTop = 0;
// Create scrollbar dynamically
track.append(thumb);
scrollbar.append(track);
content.prepend(scrollbar); // .prepend() is important here due to CSS
const updateThumb = () => {
const contentHeight = content.scrollHeight;
const visibleHeight = content.clientHeight;
const thumbHeight = Math.max((visibleHeight / contentHeight) * visibleHeight, 10);
const scrollRatio = content.scrollTop / (content.scrollHeight - content.clientHeight);
const maxThumbTop = scrollbar.clientHeight - thumb.clientHeight;
thumb.style.height = `${thumbHeight}px`;
thumb.style.top = `${scrollRatio * maxThumbTop}px`;
};
const handleDown = (ev) => {
scrollbar.setPointerCapture(ev.pointerId);
startY = ev.clientY;
startScrollTop = content.scrollTop;
};
const handleMove = (ev) => {
if (!scrollbar.hasPointerCapture(ev.pointerId)) return;
const deltaY = ev.clientY - startY;
const scrollRatio = (content.scrollHeight - content.clientHeight) / (scrollbar.clientHeight - thumb.clientHeight);
content.scrollTop = startScrollTop + deltaY * scrollRatio;
};
const handleScroll = () => {
requestAnimationFrame(updateThumb);
// sessionStorage.setItem("scrollPosition", content.scrollTop);
};
const handleLoad = () => {
// const savedScrollPosition = sessionStorage.getItem("scrollPosition") ?? 0;
// content.scrollTop = parseInt(savedScrollPosition, 10);
updateThumb();
};
scrollbar.addEventListener("pointerdown", handleDown);
scrollbar.addEventListener("pointermove", handleMove);
content.addEventListener("scroll", handleScroll, {
passive: true
});
addEventListener("resize", updateThumb);
addEventListener("load", handleLoad);
updateThumb();
};
// Apply custom scrollbar to all .content elements
els(".content").forEach(customScrollbar);
// if ("scrollRestoration" in history) {
// history.scrollRestoration = "manual";
// }
* { margin: 0; box-sizing: border-box; }
body { font: 1rem/1.4 system-ui, sans-serif; }
.content {
position: relative;
height: 100dvh;
overflow-y: scroll;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
section {
height: 100dvh;
display: grid;
place-items: center;
&:nth-child(odd) { background: #ddd; }
&:nth-child(even) { background: #ace; }
}
}
.scrollbar {
position: sticky;
top: 0;
float: right;
right: 20px;
width: 0px;
/* To not get in the way of float space */
height: 100%;
touch-action: none;
.track {
position: absolute;
top: 0;
right: 0;
width: 40px;
height: 100%;
background-color: #333;
}
.thumb {
width: 100%;
background: #888;
position: absolute;
top: 0;
transition: background 0.2s;
&:hover,
&:active {
background: #0bf;
}
}
}
<main class="content">
<section>1</section>
<section>2</section>
<section>3</section>
<section>4</section>
</main>
To the reader: If you find another way to position the scrollbars inside the overflow scrollable parent I'd be glad to hear about other solutions / ideas.