Custom Scrollbar
How to build a feature like this
A virtualized region still needs a scrollbar, but the browser's native bar is not always ideal: its look is platform-dependent, and for content larger than a browser's ~10M px scroll limit the native bar stops tracking a scaled virtual space correctly. VirtualScroll solves both with a virtual scrollbar - an overlay bar rendered inside the scroll container that it positions and sizes itself. There are three integration levels, from least to most control: let the component draw its built-in themed bar (it appears automatically when coordinate scaling kicks in, or whenever you force it), restyle that bar purely with CSS variables, or replace its chrome with the #scrollbar slot and keep your own markup. Pick the level that matches how custom the look must be. The tradeoff is consistent across all of them: the bar overlays the content, so rows can pass underneath it, and the bar's geometry is always proportional to what is visible rather than native-scrollbar-accurate.
1. Size the host and enable the virtual bar
Start from any scrollable setup - a uniform-height vertical list, a horizontal strip, or a direction="both" grid. Virtualization needs a definite viewport, so give the host an explicit or flex-derived height (in a flex/grid parent add min-height: 0 so it can shrink). Then set virtual-scrollbar (default false) to true to always draw the themed overlay bar. Forcing it is about consistency and performance: identical styling in every browser regardless of the OS default, plus a bar whose rendering and drag cost stay constant however long the list is, instead of a native bar that has to track a multi-million-pixel scroll area. You do not strictly have to set it for massive content - once any axis exceeds the browser limit the engine engages coordinate scaling and shows the bars automatically, because only a library-drawn bar can drive the scaled space - but forcing is what keeps a modest list visually identical to a huge one. One bar is drawn per active axis (direction="both" yields a vertical and a horizontal bar); the bars are suppressed when the scroll container is the window/body, which scroll natively.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
const rows = Array.from({ length: 50_000 }, (_, i) => `Row ${ i }`);
</script>
<template>
<!-- virtual-scrollbar forces the themed overlay bar. Even without the prop the
bar appears automatically once an axis exceeds the browser ~10M px scroll
limit, because coordinate scaling then needs a bar it fully controls. -->
<VirtualScroll
class="list"
:items="rows"
virtual-scrollbar
aria-label="List with a themed scrollbar"
>
<template #item="{ index }">
<div class="row">{{ index }}</div>
</template>
</VirtualScroll>
</template>
<style scoped>
.list {
height: 480px; /* a definite viewport is required for virtualization */
}
.row {
height: 100%;
box-sizing: border-box;
line-height: 40px;
padding-inline: 1rem;
}
</style>2. Restyle the built-in bar with CSS variables
The default bar is a single component whose color and metrics come from CSS custom properties, so most reskinning needs no markup at all. Set any of the --vs-scrollbar-* properties on the VirtualScroll host or any ancestor - the bar resolves them through var() with built-in light/dark fallbacks:
--vs-scrollbar-size- bar thickness (the vertical bar's width, the horizontal bar's height).--vs-scrollbar-radius- corner radius of the track and thumb.--vs-scrollbar-bg- track background.--vs-scrollbar-thumb-bg- thumb fill.--vs-scrollbar-thumb-hover-bg- thumb fill while hovered or dragged.--vs-scrollbar-has-cross-gap(0/1) and--vs-scrollbar-cross-gap- a corner notch for when both axes are active, so the vertical and horizontal bars do not overlap where they meet; set1plus a gap size only in two-axis layouts.
For a single-axis list you normally set only the first five; the cross-gap pair is meaningful only when two bars share a corner in a direction="both" grid. Note the scope of these variables: they theme the default bar; they also reach custom chrome built through the #scrollbar slot (next step), because the bound track/thumb classes resolve them as defaults - the slot's bindings carry geometry and interaction, not colors, so add your own classes where you want to override them.
/* Set these on the VirtualScroll host (or any ancestor): the themed bar reads
them through var(--vs-scrollbar-*) and falls back to light/dark defaults. */
.scroll-list {
--vs-scrollbar-size: 10px; /* thickness: vertical bar width / horizontal height */
--vs-scrollbar-radius: 5px; /* thumb and track corner radius */
--vs-scrollbar-bg: #eceff1; /* track background */
--vs-scrollbar-thumb-bg: #90a4ae; /* thumb fill */
--vs-scrollbar-thumb-hover-bg: #607d8b; /* thumb while hovered or dragged */
}
/* Only when both axes are active (direction="both") and the two bars meet at
a corner: has-cross-gap = 1 leaves a notch so they do not overlap, and
cross-gap is that notch's thickness (defaults to --vs-scrollbar-size). */
.two-axis {
--vs-scrollbar-has-cross-gap: 1;
--vs-scrollbar-cross-gap: 8px;
}3. Replace the chrome with the #scrollbar slot
When the built-in bar's look is not enough - custom shapes, gradients, per-axis colors, animations - provide a #scrollbar slot. It is invoked once per active axis, and only while content actually overflows that axis (there is no call when totalSize <= viewportSize), so you need not detect overflow yourself. Each invocation gives you the axis, the geometry as percentages (positionPercent, viewportPercent, thumbSizePercent, thumbPositionPercent), a reactive isDragging, and two binding bundles:
trackProps-v-bindonto the element that is the track.thumbProps-v-bindonto the element that is the thumb.scrollbarProps- the same state regrouped so you can forward it straight into<VirtualScrollbar v-bind="scrollbarProps" />if you would rather use the component form.
Two details are worth calling out. First, providing the slot does not itself turn the bars on: the slot is rendered only while virtual bars are active (showVirtualScrollbars - forced via the virtual-scrollbar prop, or automatic when content passes the browser limit), so pair it with virtual-scrollbar for ordinary content. Second, the slot replaces the default bar's markup, but the bound track/thumb classes still resolve the --vs-scrollbar-* variables as defaults - the bundles carry geometry, ARIA, and interaction but no separate theming API, so add your own colors/shape where you want to differ from those defaults. Binding them is what makes a custom chrome functional, not just decorative: style the slot elements however you like (utility classes or scoped CSS both work), and use isDragging to reflect the drag state in CSS or drive an active class.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
const rows = Array.from({ length: 5000 }, (_, i) => `Row ${ i }`);
</script>
<template>
<VirtualScroll class="list" :items="rows" virtual-scrollbar>
<!-- Called once per active axis, only while content overflows that axis.
trackProps / thumbProps carry the geometry (thumb size + position as
percentages), the ARIA attributes, and the interaction listeners: a
click on the track jumps, pointer-down on the thumb drags. Binding
them means your custom chrome is functional, not just decorative. -->
<template #scrollbar="{ axis, trackProps, thumbProps, isDragging }">
<div
v-if="axis === 'vertical'"
v-bind="trackProps"
class="track"
:class="{ dragging: isDragging }"
>
<div v-bind="thumbProps" class="thumb" />
</div>
</template>
<template #item="{ index }">
<div class="row">{{ index }}</div>
</template>
</VirtualScroll>
</template>
<style scoped>
.list {
height: 480px;
}
.row {
height: 100%;
box-sizing: border-box;
line-height: 40px;
padding-inline: 1rem;
}
.track {
position: absolute;
inset-block: 2px;
inset-inline-end: 2px;
width: 12px;
}
.thumb {
position: absolute;
width: 100%;
border-radius: 6px;
background: #6366f1;
}
.track.dragging .thumb {
background: #4f46e5;
}
</style>4. Pick the level that fits: built-in, slot, or standalone
The three approaches cover a spectrum and you can even mix them across axes. Reach for the built-in bar plus CSS variables when the default shape is acceptable and you only need brand colors and thickness; use the #scrollbar slot when the visible chrome must differ while you still want the engine to own geometry, ARIA, and pointer interaction. A third option is available when the scrollbar should control content that VirtualScroll does not drive at all: the VirtualScrollbar component is exported and can be mounted over any scrollable element, fed total-size/viewport-size/position and writing back through @scroll-to-offset (see the Independent Scrollbars example). That pattern is the right fit when you want the scrollbar UX without virtualization.
- Scroll Status
- Directionboth
- Current Item #- ×
- Rendered Range #0:0
- Total Size (px)0w ×0h
- Viewport Size (px)0w ×0h
- Scroll Offset (px)0x ×0y