Auto-Hiding Scrollbars with OverlayScrollbars

TL;DR

Native scrollbars break dark UI designs and can’t auto-hide on desktop. OverlayScrollbars replaces them with customizable overlay scrollbars. In LiveView, use a hook to handle DOM updates.

The Problem

You’re building a dark-themed UI. The native scrollbar sticks out - a gray Windows bar on your carefully crafted #1a1a2e background.

Worse, native scrollbars on desktop never auto-hide. They’re always there, taking up space, breaking your layout’s clean lines.

CSS can hide scrollbars with scrollbar-width: none or ::-webkit-scrollbar { display: none }. But then users lose visual feedback about scrollable content.

Why Not Just CSS?

CSS scrollbar-color and scrollbar-width help but have limits:

  • Can’t animate opacity or visibility
  • Can’t auto-hide on scroll idle
  • Can’t overlay content (they always take space)
  • Limited styling (no gradients, no custom shapes)

You need JavaScript to detect scroll activity and toggle visibility. That’s where OverlayScrollbars comes in.

How OverlayScrollbars Works

The library wraps your scrollable element in a structure that hides native scrollbars and renders custom ones as overlays.

The DOM Transformation

When you initialize OverlayScrollbars on an element, it transforms this:

<div class="my-container">
<p>Content here...</p>
</div>

Into this:

<div class="my-container" data-overlayscrollbars="host">
<div data-overlayscrollbars-padding>
<div data-overlayscrollbars-viewport>
<div data-overlayscrollbars-content>
<p>Content here...</p>
</div>
</div>
</div>
<div class="os-scrollbar os-scrollbar-horizontal">
<div class="os-scrollbar-track">
<div class="os-scrollbar-handle"></div>
</div>
</div>
<div class="os-scrollbar os-scrollbar-vertical">
<div class="os-scrollbar-track">
<div class="os-scrollbar-handle"></div>
</div>
</div>
</div>

The class names are defined in classnames.ts:

export const classNameScrollbar = 'os-scrollbar';
export const classNameScrollbarHorizontal = `${classNameScrollbar}-horizontal`;
export const classNameScrollbarVertical = `${classNameScrollbar}-vertical`;
export const classNameScrollbarTrack = `${classNameScrollbar}-track`;
export const classNameScrollbarHandle = `${classNameScrollbar}-handle`;

Creating the Scrollbar Elements

In scrollbarsSetup.elements.ts, the library creates the scrollbar DOM:

const generateScrollbarDOM = (isHorizontal?: boolean): ScrollbarStructure => {
const scrollbarClassName = isHorizontal
? classNameScrollbarHorizontal
: classNameScrollbarVertical;
const scrollbar = createDiv(`${classNameScrollbar} ${scrollbarClassName}`);
const track = createDiv(classNameScrollbarTrack);
const handle = createDiv(classNameScrollbarHandle);
const result = {
_scrollbar: scrollbar,
_track: track,
_handle: handle,
};
push(destroyFns, [
appendChildren(scrollbar, track),
appendChildren(track, handle),
bind(removeElements, scrollbar),
scrollbarsSetupEvents(result, scrollbarsAddRemoveClass, isHorizontal),
]);
return result;
};

Hiding Native Scrollbars

The library uses CSS to hide native scrollbars on the viewport element:

[data-overlayscrollbars-viewport~=scrollbarHidden] {
scrollbar-width: none !important;
}
[data-overlayscrollbars-viewport~=scrollbarHidden]::-webkit-scrollbar {
appearance: none !important;
display: none !important;
width: 0 !important;
height: 0 !important;
}

Detecting Native Scrollbar Size

How does it know if native scrollbars are overlaid (macOS) or take space (Windows)?

In environment.ts, it creates an invisible test element:

const getNativeScrollbarSize = (
measureElm: HTMLElement,
measureElmChild: HTMLElement,
clear?: boolean
): XY => {
// Fix weird Safari issue by appending twice
appendChildren(document.body, measureElm);
appendChildren(document.body, measureElm);
const cSize = getClientSize(measureElm);
const oSize = getOffsetSize(measureElm);
const fSize = getFractionalSize(measureElmChild);
if (clear) {
removeElements(measureElm);
}
return {
x: oSize.h - cSize.h + fSize.h,
y: oSize.w - cSize.w + fSize.w,
};
};

It compares offsetSize with clientSize. The difference is the scrollbar size. If zero, scrollbars are already overlaid natively.

The test element styles:

const envStyle = `.${classNameEnvironment}{
scroll-behavior:auto!important;
position:fixed;
opacity:0;
visibility:hidden;
overflow:scroll;
height:200px;
width:200px;
z-index:-1
}`;

CSS Custom Properties for Handle Position

The clever bit: handle position uses CSS custom properties, not JavaScript transforms.

const cssCustomPropViewportPercent = '--os-viewport-percent';
const cssCustomPropScrollPercent = '--os-scroll-percent';
const cssCustomPropScrollDirection = '--os-scroll-direction';

The CSS uses these to position the handle:

.os-scrollbar-vertical .os-scrollbar-handle {
top: calc(var(--os-scroll-percent-directional) * 100%);
transform: translateY(calc(var(--os-scroll-percent-directional) * -100%));
height: calc(var(--os-viewport-percent) * 100%);
}

JavaScript only updates these CSS variables on scroll:

const refreshScrollbarsHandleLength = () => {
const viewportPercent = getViewportPercent();
const createScrollbarStyleFn =
(axisViewportPercent: number): ScrollbarStyleFn =>
(structure: ScrollbarStructure) => [
structure._scrollbar,
{
[cssCustomPropViewportPercent]: roundCssNumber(axisViewportPercent) + '',
},
];
scrollbarStyle(horizontalScrollbars, createScrollbarStyleFn(viewportPercent.x));
scrollbarStyle(verticalScrollbars, createScrollbarStyleFn(viewportPercent.y));
};

The browser handles positioning with CSS calc(). This is more performant than recalculating positions in JavaScript on every scroll event.

Using It in LiveView

LiveView’s DOM patching destroys OverlayScrollbars elements on updates. The library has no idea LiveView just replaced the DOM.

The Hook

Create a hook that initializes OverlayScrollbars and re-initializes after LiveView updates:

import { OverlayScrollbars } from 'overlayscrollbars'
const PortfolioHooks = {
AutoHideScrollbar: {
initScrollbar() {
this.scrollbar = OverlayScrollbars(this.el, {
scrollbars: {
theme: 'os-theme-portfolio-hub',
visibility: 'auto',
autoHide: 'never', // We handle hiding ourselves
dragScroll: true,
clickScroll: true,
},
overflow: {
x: 'hidden',
},
})
this.hideScrollbar()
},
showScrollbar() {
const scrollbarElements = this.el.querySelectorAll('.os-scrollbar')
scrollbarElements.forEach(sb => {
sb.style.opacity = '1'
sb.style.visibility = 'visible'
})
},
hideScrollbar() {
const scrollbarElements = this.el.querySelectorAll('.os-scrollbar')
scrollbarElements.forEach(sb => {
sb.style.opacity = '0'
sb.style.visibility = 'hidden'
})
},
mounted() {
this.initScrollbar()
this.el.addEventListener('scroll', () => {
this.showScrollbar()
clearTimeout(this.hideTimeout)
this.hideTimeout = setTimeout(() => this.hideScrollbar(), 1200)
}, true)
},
updated() {
// Check if OverlayScrollbars elements still exist
const scrollbarElements = this.el.querySelectorAll('.os-scrollbar')
if (scrollbarElements.length === 0) {
// LiveView destroyed them, reinitialize
if (this.scrollbar) {
this.scrollbar.destroy()
}
this.initScrollbar()
} else if (this.scrollbar) {
this.scrollbar.update()
}
},
destroyed() {
if (this.scrollbar) {
this.scrollbar.destroy()
}
}
}
}

Why Manual Auto-Hide?

OverlayScrollbars has built-in autoHide: 'scroll'. But it didn’t play well with LiveView’s DOM updates in my testing. The visibility state got confused after patches.

Manual control with setTimeout is simpler and predictable.

Attaching the Hook

In your LiveView template:

<main id="main-content" phx-hook="AutoHideScrollbar">
<%= @inner_content %>
</main>

Custom Theming

Create a theme by targeting the .os-theme-{name} class:

.os-theme-portfolio-hub.os-scrollbar {
--os-size: 8px;
--os-padding-perpendicular: 0px;
--os-track-border-radius: 4px;
--os-handle-border-radius: 4px;
}
.os-theme-portfolio-hub .os-scrollbar-track {
background: #1a1a2e;
}
.os-theme-portfolio-hub .os-scrollbar-handle {
background: linear-gradient(to bottom, #ddaa44, #cc9933, #996622);
}
.os-theme-portfolio-hub .os-scrollbar-handle:hover {
background: linear-gradient(to bottom, #ffcc66, #ddaa44, #cc9933);
}

Then reference it in the options:

OverlayScrollbars(element, {
scrollbars: { theme: 'os-theme-portfolio-hub' }
})

How It Detects Size Changes

OverlayScrollbars needs to know when content size changes. ResizeObserver handles most cases, but what about CSS transitions or images loading?

The library uses a clever scroll-based detection trick. When ResizeObserver isn’t available or doesn’t catch everything, it creates an invisible observer element with an oversized child:

<div class="os-size-observer-listener">
<div class="os-size-observer-listener-item-final"
style="width: 200%; height: 200%">
</div>
</div>

When the parent container resizes, the scroll position of this hidden element changes, firing a scroll event. The library listens to these scroll events to detect size changes that ResizeObserver might miss.

Limitations

Performance with frequent updates: If LiveView patches the DOM constantly, reinitializing OverlayScrollbars adds overhead. Consider debouncing or using phx-update="ignore" for the scrollbar container.

Mobile: Touch scrolling works, but mobile browsers already have nice overlay scrollbars. You might want to skip initialization on mobile.

Nested scrollbars: Multiple nested OverlayScrollbars instances work but can be tricky to style without conflicts.

References