Independent Scrollbars

This example shows how to use VirtualScrollbar components independently from VirtualScroll. They control a standard div with overflow: auto and hidden scrollbars, providing a custom scroll interface.

How to build a feature like this

VirtualScrollbar is exported on its own, so you can reuse the exact scrollbar UX on content that VirtualScroll does not drive - a plain overflow: auto element, a grid inside a fixed box, a pager around a canvas, anywhere a scrollbar would normally come from the browser. The component never reads your DOM. You hand it three numbers per axis - total-size (content), viewport-size (visible area), position (current scroll) - and it answers with one callback, @scroll-to-offset, whose target you apply to your own scroller. Thumb sizing, track-click jumps, and thumb dragging are all handled internally, so the visible scrollbar is fully interactive with almost no wiring. The tradeoff is that you own the state pipeline VirtualScroll would manage for you: a scroll listener to keep position fresh and a ResizeObserver to keep viewport-size accurate.

1. Start from a real scroller with its native bar hidden

Begin with a normal scrollable element - an overflow: auto box whose content is as large as you need on each axis. Because the custom bars are drawn over it, hide the native scrollbar (scrollbar-width: none plus the WebKit rules) so the two affordances do not both show. The library bars are absolutely positioned, so the element you overlay them on (or an ancestor) must establish a positioning context with position: relative. Content here is fully rendered and sized to its model width/height; this pattern is about the scrollbar, not about virtualization.

2. Keep the bar fed with live numbers

The bar computes its thumb purely from the three props, so keep each prop in sync with the DOM as the user scrolls and the box resizes:

  • total-size - the scrollable content size on that axis (the natural scroll range + the viewport).
  • position - the current scroll offset; read scrollTop/scrollLeft in the element's native @scroll event and store into a ref.
  • viewport-size - the visible size; the bar cannot infer it, so measure clientWidth/clientHeight on mount and again with a ResizeObserver when the box resizes.
  • is-rtl - set for a right-to-left layout (default false) so the horizontal thumb offset mirrors correctly; aria-label (and an optional container-id) wire up the accessibility attributes.

Because position and viewport-size are ordinary reactive numbers, this also composes with non-DOM sources - e.g. a translated/scaled coordinate space or a model-driven offset - not just a native scroller.

<script setup lang="ts">
import { VirtualScrollbar } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { onMounted, onUnmounted, ref } from 'vue';

const scroller = ref<HTMLElement | null>(null);
const contentWidth = ref(8000); // your content size on each axis
const contentHeight = ref(6000);
const scrollX = ref(0); // current native scroll position, fed to the bars
const scrollY = ref(0);
const viewportWidth = ref(0); // measured, because nothing virtualizes here
const viewportHeight = ref(0);

function onScroll(e: Event) {
  const el = e.target as HTMLElement;
  scrollX.value = el.scrollLeft;
  scrollY.value = el.scrollTop;
}

function measure() {
  const el = scroller.value;
  if (!el) {
    return;
  }
  viewportWidth.value = el.clientWidth;
  viewportHeight.value = el.clientHeight;
}

let ro: ResizeObserver | null = null;
onMounted(() => {
  measure();
  ro = new ResizeObserver(measure);
  if (scroller.value) {
    ro.observe(scroller.value);
  }
});
onUnmounted(() => {
  ro?.disconnect();
});

// The one thing a bar asks back: write the target offset to your element.
function scrollToX(v: number) {
  if (scroller.value) {
    scroller.value.scrollLeft = v;
  }
}
function scrollToY(v: number) {
  if (scroller.value) {
    scroller.value.scrollTop = v;
  }
}
</script>

<template>
  <div class="stage">
    <!-- A real, scrollable element whose native bar is hidden; the two
         VirtualScrollbar components below are the visible scrollbars. -->
    <div ref="scroller" class="viewport scrollbar-hide" @scroll="onScroll">
      <div
        class="content"
        :style="{
          width: `${ contentWidth }px`,
          height: `${ contentHeight }px`,
        }"
      />
    </div>

    <VirtualScrollbar
      axis="vertical"
      :total-size="contentHeight"
      :viewport-size="viewportHeight"
      :position="scrollY"
      aria-label="Vertical scroll"
      @scroll-to-offset="scrollToY"
    />

    <VirtualScrollbar
      axis="horizontal"
      :total-size="contentWidth"
      :viewport-size="viewportWidth"
      :position="scrollX"
      aria-label="Horizontal scroll"
      @scroll-to-offset="scrollToX"
    />
  </div>
</template>

<style scoped>
.stage {
  position: relative; /* the absolutely positioned bars anchor to this box */
  width: 600px;
  height: 400px;
}
.viewport {
  width: 100%;
  height: 100%;
  overflow: auto;
}
/* Hide the native bar so it does not double with the custom ones. */
.scrollbar-hide {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
  display: none;
}
/* A light grid so scrolling is visible against an empty surface. */
.content {
  background-image:
    linear-gradient(#0001 1px, transparent 1px),
    linear-gradient(90deg, #0001 1px, transparent 1px);
  background-size: 40px 40px;
}
</style>

3. Apply the offset the bar asks for

The entire contract in the other direction is @scroll-to-offset: when the user drags the thumb or clicks the track, the component resolves the pointer travel (or click position) against the totals you supplied and emits the resulting pixel target. All you do is write it back onto your scroller - scrollTop = v for a vertical bar, scrollLeft = v for horizontal. Setting the native property moves the content, which fires @scroll, which updates position, which moves the thumb - a closed loop that needs no math on your side. Mount one component per axis and give each its own axis, totals, viewport, and position.

4. Handle the corner when both axes are active

With a vertical bar on the right edge and a horizontal bar on the bottom edge of the same box, the two tracks meet at the corner. Leave a notch so they do not overlap: set --vs-scrollbar-has-cross-gap: 1 and give --vs-scrollbar-cross-gap a pixel value on the container that holds the bars. Each track then shortens by that gap before the corner.

/* Both bars overlay the same box, so where they meet (bottom-right corner)
   leave a notch: has-cross-gap = 1 shrinks each bar by cross-gap so they do
   not overlap. Set both on the container that holds the bars. */
.stage {
  --vs-scrollbar-has-cross-gap: 1;
  --vs-scrollbar-cross-gap: 8px;
}
Content Width
Content Height
Independent Content
2000 × 2000 pixels
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
  • Scroll Status
  • Direction
    both
  • Current Item #
    - × 0
  • Rendered Range #
    0:0
  • Total Size (px)
    2000w ×2000h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y