javascripthtmlcssscrollbar

Custom scrollbar div blocks wheel scrolling on hover


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.

Scrollbar track and thumb (wheel scroll issue)

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:

Expected Behavior:


Solution

  • Custom scrollbar + wheel, mouse, and touch support

    Register wheel event

    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.

    Styling the scrollbar when it's a child of a 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>

    Now for the fun part: JavaScript

    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:

    Final 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.