Building detached-scrollbar: A Fully Customizable Scrollbar Decoupled From the DOM
Native browser scrollbars are one of the most frustrating parts of front-end development. You can't fully style them across browsers, and they're always glued to the element they scroll. What if you need a scrollbar in a completely different part of the layout — like a timeline scrubber below a carousel, or a progress indicator in a sticky header?
That's exactly the problem I set out to solve with detached-scrollbar — a zero-dependency TypeScript library that decouples the scrollbar from the element it controls.
The Problem with Native Scrollbars
If you've ever tried to customize a scrollbar, you know the pain:
- Limited styling —
::-webkit-scrollbaronly works in Chromium browsers. Firefox usesscrollbar-colorbut with far fewer options. Safari has its own quirks. - Fixed position — The scrollbar always appears attached to the scrollable element. You can't move it elsewhere in the DOM.
- No multi-element sync — You can't natively have one scrollbar control two separate scrollable areas.
- Inconsistent behavior — Every browser and OS renders scrollbars differently.
Most existing libraries solve this by wrapping your content in a custom container, hiding the native scrollbar, and rendering a fake one next to it. But that still couples the scrollbar to the content — and adds a heavy runtime dependency.
A Different Approach: Detach the Scrollbar Entirely
The core idea behind detached-scrollbar is simple: the scrollbar and the content share no native scroll relationship. They are linked by a single value — ratio — a number between 0 and 1.
npm install detached-scrollbar
Here's what makes it different:
- Zero dependencies — vanilla TypeScript, ~2 KB gzipped
- Place it anywhere — the track and thumb are regular HTML elements you style with CSS
- Multi-content sync — one scrollbar can control multiple elements simultaneously
- Fully accessible —
role="slider",aria-valuenow, keyboard support (arrows, Home, End) - Touch-ready — mouse, touch, and keyboard out of the box
- Framework-agnostic — works with React, Vue, Svelte, or plain HTML
Quick Start
You need four elements: a track, a thumb inside it, a viewport with overflow: hidden, and the content inside the viewport.
<!-- The scrollbar (can live anywhere in the DOM) -->
<div class="track" id="track">
<div class="thumb" id="thumb"></div>
</div>
<!-- The scrollable area -->
<div class="viewport" id="viewport">
<div class="content" id="content">
<!-- your wide or tall content here -->
</div>
</div>
Style the track and thumb however you want — they're plain HTML:
.track {
height: 30px;
position: relative;
}
.thumb {
height: 30px;
position: absolute;
left: 0;
top: 0;
cursor: grab;
background: #D4AF37;
border-radius: 40px;
}
.viewport {
overflow: hidden;
position: relative;
}
.content {
position: absolute;
width: max-content;
}
Then wire it up with one function call:
import { DetachedScrollbar } from 'detached-scrollbar';
const scrollbar = new DetachedScrollbar({
track: '#track',
thumb: '#thumb',
content: '#content',
viewport: '#viewport',
});
The library automatically calculates thumb size, wires up drag/touch/keyboard events, and keeps thumb and content in sync.
How It Works Under the Hood
The entire system is driven by two equations and one shared value: ratio.
// ratio = 0 → start, ratio = 1 → end
thumbPosition = ratio * (trackSize - thumbSize)
contentPosition = -ratio * (contentSize - viewportSize)
Every operation — dragging, clicking the track, pressing arrow keys, calling scrollTo() programmatically, or handling a resize — reads or writes ratio, then applies these two equations. That's it.
Here's the actual positioning code from the source:
private applyPosition(): void {
const thumbPos = this._ratio * (this.trackSize() - this.thumbSize());
const contentPos = -this._ratio * (this.contentSize() - this.viewportSize());
if (this.isHorizontal()) {
this.thumb.style.left = thumbPos + 'px';
for (const el of this.contentEls) {
el.style.left = contentPos + 'px';
}
} else {
this.thumb.style.top = thumbPos + 'px';
for (const el of this.contentEls) {
el.style.top = contentPos + 'px';
}
}
this.thumb.setAttribute('aria-valuenow', String(Math.round(this._ratio * 100)));
this.onScrollCb?.(this._ratio);
}
The thumb size is calculated proportionally — just like a native scrollbar would:
private sizeThumb(): void {
const visible = this.viewportSize() / this.contentSize();
const size = this.trackSize() * Math.min(visible, 1);
if (this.isHorizontal()) {
this.thumb.style.width = size + 'px';
} else {
this.thumb.style.height = size + 'px';
}
}
Accessibility Was Non-Negotiable
Custom scrollbars are notorious for breaking accessibility. I made sure this library handles it properly from the start:
- The thumb gets
role="slider"witharia-valuemin,aria-valuemax, andaria-valuenow - Arrow keys (Left/Right or Up/Down depending on direction) move in configurable steps
- Home/End keys jump to start/end
- The thumb is focusable via
tabindex="0"
All of this is set up automatically in the constructor — you don't need to add any ARIA attributes yourself.
Advanced: Syncing Multiple Content Areas
One of the more interesting use cases is controlling multiple elements with a single scrollbar. Think of a comparison view where two rows need to scroll in lockstep:
const scrollbar = new DetachedScrollbar({
track: '#track',
thumb: '#thumb',
content: ['#row1', '#row2'], // Array of content elements
viewport: '#viewport',
});
Both elements get the same contentPosition applied, so they stay perfectly in sync. No event forwarding, no scrollLeft mirroring — just the same ratio applied to both.
The Full API
The constructor takes an options object with sensible defaults:
| Option | Default | Description |
|---|---|---|
track | required | Track element or CSS selector |
thumb | required | Thumb element or CSS selector |
content | required | Content element(s) — pass an array for multi-sync |
viewport | required | Viewport/clipping element |
direction | 'horizontal' | Scroll axis |
keyboardSteps | 50 | Arrow key steps across full range |
trackClick | true | Click track to jump |
autoResize | true | Recalculate on window resize |
And the instance exposes a minimal, clean API:
scrollTo(ratio)— scroll to a position between 0 and 1scrollToOffset(px)— center a pixel offset in the viewportupdate()— recalculate after layout changesdestroy()— remove all listeners and clean upratio— read-only current position (0–1)isDragging— read-only drag state
Design Decisions
A few deliberate choices I made during development:
- No
requestAnimationFrame— Position updates use direct DOM writes. For a scrollbar, the overhead of rAF scheduling adds latency without meaningful benefit since each event only triggers one update. - CSS selectors or elements — Every element option accepts either a string selector or an HTMLElement. This makes it work equally well with vanilla JS, React refs, or Vue template refs.
- No CSS injection — The library doesn't add any styles. You bring your own CSS for the track and thumb. This keeps it truly zero-opinion about visuals.
- Debounced resize — Window resize events are debounced (configurable, default 100ms) to avoid layout thrashing.
Try It Out
The library is open source under the MIT license:
- GitHub: github.com/firasdeveloper/detached-scrollbar
- npm:
npm install detached-scrollbar - Live Demo: firasdeveloper.github.io/detached-scrollbar
The entire source is a single ~250-line TypeScript file with full JSDoc comments. No build-time magic, no hidden complexity — just two equations and clean event handling.
Have a use case or feedback? Get in touch or open an issue on GitHub.