/ Examples
scrollbar

Scrollbar

Explore every scrollbar mode and option. Switch between native, custom (withScrollbar), and none. Configure auto-hide, gutter, hover behavior, padding, click behavior, and track width live.

Contact list

0% 0.00 / 0.00 px/ms 0 / 0 items
mode native gutter overlay
Source
// Scrollbar — showcase all withScrollbar options
// Uses a contact list as the canvas to demonstrate native, custom, and none modes.

import { vlist, withScrollbar, withSelection, withSnapshots } from "vlist";
import { makeContacts } from "../../src/data/people.js";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import "./controls.js";

// =============================================================================
// Constants
// =============================================================================

const STORAGE_KEY = "scrollbar-list";
const TOTAL = 1_000;
const ITEM_HEIGHT = 64;

// =============================================================================
// Data
// =============================================================================

export const contacts = makeContacts(TOTAL).sort((a, b) =>
  a.lastName.localeCompare(b.lastName),
);

// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================

export let mode = "native"; // "native" | "custom" | "none"
export let autoHide = true;
export let autoHideDelay = 1000;
export let gutterEnabled = false;
export let showOnHover = true;
export let showOnViewportEnter = true;
export let padding = 2;
export let minThumbSize = 15;
export let clickBehavior = "page"; // "jump" | "page"
export let list = null;

export function setMode(v) {
  mode = v;
}
export function setAutoHide(v) {
  autoHide = v;
}
export function setAutoHideDelay(v) {
  autoHideDelay = v;
}
export function setGutterEnabled(v) {
  gutterEnabled = v;
}
export function setShowOnHover(v) {
  showOnHover = v;
}
export function setShowOnViewportEnter(v) {
  showOnViewportEnter = v;
}
export function setPadding(v) {
  padding = v;
}
export function setMinThumbSize(v) {
  minThumbSize = v;
}
export function setClickBehavior(v) {
  clickBehavior = v;
}

// =============================================================================
// Template
// =============================================================================

const renderContact = (item) => `
  <div class="contact">
    <div class="contact__avatar" style="background:${item.color};color:${item.textColor}">${item.initials}</div>
    <div class="contact__info">
      <div class="contact__name">${item.firstName} ${item.lastName}</div>
      <div class="contact__detail">${item.department} · ${item.email}</div>
    </div>
  </div>
`;

// =============================================================================
// Stats — shared info bar
// =============================================================================

export const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => contacts.length,
  getItemSize: () => ITEM_HEIGHT,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create / recreate list
// =============================================================================

export function createList() {
  if (list) {
    list.destroy();
    list = null;
  }

  const container = document.getElementById("list-container");
  container.innerHTML = "";

  const scrollConfig = {};
  if (mode === "none") scrollConfig.scrollbar = "none";

  const builder = vlist({
    container: "#list-container",
    ariaLabel: "Scrollbar demo — contact list",
    scroll: scrollConfig,
    item: { height: ITEM_HEIGHT, template: renderContact },
    items: contacts,
  });

  if (mode === "custom") {
    builder.use(
      withScrollbar({
        autoHide,
        autoHideDelay,
        gutter: gutterEnabled,
        showOnHover,
        showOnViewportEnter,
        padding,
        clickBehavior,
        minThumbSize,
      }),
    );
  }

  builder.use(withSelection());
  builder.use(withSnapshots({ autoSave: STORAGE_KEY }));

  list = builder.build();

  list.on("scroll", updateInfo);
  list.on("range:change", updateInfo);
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });

  updateInfo();
  updateContext();
}

// =============================================================================
// Info bar — right side
// =============================================================================

const infoMode = document.getElementById("info-mode");
const infoGutter = document.getElementById("info-gutter");

export function updateContext() {
  if (infoMode) infoMode.textContent = mode;
  if (infoGutter) {
    infoGutter.textContent =
      mode === "custom" ? (gutterEnabled ? "stable" : "overlay") : "—";
  }
}

// =============================================================================
// Init
// =============================================================================

createList();
/* Scrollbar example — styles */

/* ============================================================================
   Contact item (64px)
   ============================================================================ */

.contact {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 16px;
    height: 100%;
}

.contact__avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 600;
    font-size: 13px;
    flex-shrink: 0;
}

.contact__info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.contact__name {
    font-weight: 600;
    font-size: 14px;
    color: var(--vlist-text, #111827);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.contact__detail {
    font-size: 12px;
    color: var(--vlist-text-muted, #6b7280);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ============================================================================
   Panel — hide mode-specific sections based on current mode
   ============================================================================ */

.custom-only,
.native-only {
    display: none;
}

.mode-custom .custom-only {
    display: block;
}

.mode-native .native-only {
    display: block;
}

/* ============================================================================
   Native color input
   ============================================================================ */

.ui-color-input {
    width: 36px;
    height: 24px;
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 1px;
    cursor: pointer;
    background: none;
    flex-shrink: 0;
}

/* ============================================================================
   Disabled row (delay row when auto-hide is off)
   ============================================================================ */

.ui-row--disabled {
    opacity: 0.4;
    pointer-events: none;
}
<div class="container">
    <header>
        <h1>Scrollbar</h1>
        <p class="description">
            Explore every scrollbar mode and option. Switch between
            <strong>native</strong>,
            <strong>custom</strong> (<code>withScrollbar</code>), and
            <strong>none</strong>. Configure auto-hide, gutter, hover behavior,
            padding, click behavior, and track width live.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main">
            <h2 class="sr-only">Contact list</h2>
            <div id="list-container"></div>
        </div>

        <aside class="split-panel mode-native" id="scrollbar-panel">
            <!-- Mode -->
            <section class="ui-section">
                <h3 class="ui-title">Mode</h3>
                <div class="ui-row">
                    <div class="ui-segmented" id="mode-buttons">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-mode="native"
                        >
                            Native
                        </button>
                        <button class="ui-segmented__btn" data-mode="custom">
                            Custom
                        </button>
                        <button class="ui-segmented__btn" data-mode="none">
                            None
                        </button>
                    </div>
                </div>
            </section>

            <!-- Behavior — custom only -->
            <section class="ui-section custom-only">
                <h3 class="ui-title">Behavior</h3>

                <div class="ui-row">
                    <label class="ui-label">Auto-hide</label>
                    <label class="ui-switch">
                        <input type="checkbox" id="toggle-autohide" checked />
                        <span class="ui-switch__track"></span>
                    </label>
                </div>

                <div class="ui-row" id="delay-row">
                    <label class="ui-label">
                        Delay
                        <span class="ui-label__unit" id="delay-value"
                            >1000ms</span
                        >
                    </label>
                    <input
                        type="range"
                        id="delay-slider"
                        class="ui-slider"
                        min="200"
                        max="3000"
                        step="100"
                        value="1000"
                    />
                </div>

                <div class="ui-row">
                    <label class="ui-label">Track click</label>
                    <div class="ui-segmented" id="click-behavior-buttons">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-behavior="page"
                        >
                            Page
                        </button>
                        <button class="ui-segmented__btn" data-behavior="jump">
                            Jump
                        </button>
                    </div>
                </div>
            </section>

            <!-- Hover — custom only -->
            <section class="ui-section custom-only">
                <h3 class="ui-title">Hover</h3>

                <div class="ui-row">
                    <label class="ui-label">Show on hover</label>
                    <label class="ui-switch">
                        <input type="checkbox" id="toggle-show-hover" checked />
                        <span class="ui-switch__track"></span>
                    </label>
                </div>

                <div class="ui-row">
                    <label class="ui-label">Show on enter</label>
                    <label class="ui-switch">
                        <input type="checkbox" id="toggle-show-enter" checked />
                        <span class="ui-switch__track"></span>
                    </label>
                </div>
            </section>

            <!-- Gutter — custom only -->
            <section class="ui-section custom-only">
                <h3 class="ui-title">Gutter</h3>
                <div class="ui-row">
                    <div class="ui-segmented" id="gutter-buttons">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-gutter="false"
                        >
                            Overlay
                        </button>
                        <button class="ui-segmented__btn" data-gutter="true">
                            Stable
                        </button>
                    </div>
                </div>
            </section>

            <!-- Width & Padding — custom only -->
            <section class="ui-section custom-only">
                <h3 class="ui-title">Width</h3>
                <div class="ui-row">
                    <label class="ui-label">
                        Track
                        <span class="ui-label__unit" id="width-value">8px</span>
                    </label>
                    <input
                        type="range"
                        id="width-slider"
                        class="ui-slider"
                        min="4"
                        max="20"
                        step="1"
                        value="8"
                    />
                </div>
                <div class="ui-row">
                    <label class="ui-label">
                        Padding
                        <span class="ui-label__unit" id="padding-value"
                            >2px</span
                        >
                    </label>
                    <input
                        type="range"
                        id="padding-slider"
                        class="ui-slider"
                        min="0"
                        max="12"
                        step="1"
                        value="2"
                    />
                </div>
                <div class="ui-row">
                    <label class="ui-label">
                        Radius
                        <span class="ui-label__unit" id="radius-value"
                            >4px</span
                        >
                    </label>
                    <input
                        type="range"
                        id="radius-slider"
                        class="ui-slider"
                        min="0"
                        max="20"
                        step="1"
                        value="4"
                    />
                </div>
                <div class="ui-row">
                    <label class="ui-label">
                        Min thumb
                        <span class="ui-label__unit" id="min-thumb-value"
                            >15px</span
                        >
                    </label>
                    <input
                        type="range"
                        id="min-thumb-slider"
                        class="ui-slider"
                        min="4"
                        max="80"
                        step="1"
                        value="15"
                    />
                </div>
            </section>

            <!-- Native appearance — native only -->
            <section class="ui-section native-only">
                <h3 class="ui-title">Appearance</h3>

                <div class="ui-row">
                    <label class="ui-label">Width</label>
                    <div class="ui-segmented" id="native-width">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-width="auto"
                        >
                            Auto
                        </button>
                        <button class="ui-segmented__btn" data-width="thin">
                            Thin
                        </button>
                    </div>
                </div>

                <div class="ui-row">
                    <label class="ui-label">Gutter</label>
                    <div class="ui-segmented" id="native-gutter">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-gutter="auto"
                        >
                            Auto
                        </button>
                        <button class="ui-segmented__btn" data-gutter="stable">
                            Stable
                        </button>
                    </div>
                </div>

                <p class="ui-hint">
                    Uses <code>scrollbar-width</code> and
                    <code>scrollbar-gutter</code> — Chrome 121+, Firefox 64+,
                    Safari 18.2+. macOS overlay scrollbars only appear on
                    scroll.
                </p>
            </section>
        </aside>
    </div>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">items</span>
            </span>
        </div>
        <div class="example-info__right">
            <span class="example-info__stat">
                mode <strong id="info-mode">native</strong>
            </span>
            <span class="example-info__stat">
                gutter <strong id="info-gutter">overlay</strong>
            </span>
        </div>
    </div>
</div>