Custom Scrollbar

Demonstrates the virtual scrollbar implementation in a grid layout. The scrollbars are rendered as children of the virtual scroll container and are fully customizable. Virtual scrollbars are automatically used for massive content, but can also be forced for smaller lists to maintain consistent cross-browser styling.

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; set 1 plus 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-bind onto the element that is the track.
  • thumbProps - v-bind onto 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.

#0,0
#0,1
#1,0
#1,1
#2,0
#2,1
#3,0
#3,1
#4,0
#4,1
  • Scroll Status
  • Direction
    both
  • Current Item #
    - ×
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y