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>