Scrollbar

Scrollbars that you style with CSS, for a container or for the whole page, with markers for places in the content.

ax-scroll-view is a scroll container and ax-scrollbar draws the scrollbars of the page. The bars are an overlay over the content, and scrolling stays native, so the wheel, touch, the keys, find in page and scrollIntoView work as usual. The bars take no pointer events of their own. The component finds the bar under the pointer by geometry, so the content under a hidden bar keeps its hover and clicks.

The default look is the Windows 8 (Modern UI) scrollbar, drawn from the numbers of the Windows 8.1 XAML templates and scaled by one token, the thickness. Every part of it is a shadow part, including the thumb, the buttons, the track, the markers and the hover card. This page has an example of each way to change it. You can style the parts with ::part() (Minimal, Windows 11, Cyber Punk), add a layer by configuration (the acrylic track of Windows 11), or replace the parts with classes of your own, a kit (Windows XP).

Markers are ticks on the track for places in the content, such as headings or search hits, and a card lists their labels when the pointer comes near. Move the pointer over a preview to show its bars. They fade out after a moment. Every look on this page is a stylesheet from the package demo, so each preview shows what the package draws.

Installation

shell
npm install @ax-design/scrollbar

and import it:

js
import { register } from '@ax-design/scrollbar';
register();

If you don't use a bundler, load the script instead:

html
<script src="https://unpkg.com/@ax-design/scrollbar/build/main.js"></script>
<script>AxScrollbar.register();</script>

Introduction

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Setup

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Usage

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Styling

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Markers

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Reference

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Windows 8

With no style at all the bars are the Windows 8 scrollbar. It has a 17px bar, a square thumb, 33px buttons with a chevron, a pale track, flat ticks for markers and a square card. Wrap the content in ax-scroll-view and give the element a height. Put padding on the content and keep it clear of the bar with --ax-scrollbar-vertical-size, which the component writes on the view.

The drawing is sized from --ax-scrollbar-thickness. The thumb and the hovered marker fill the bar, and the buttons, the arrows and the markers are the same numbers scaled by thickness / 17px. Drag the slider to 8px, 17px and 24px and the whole bar follows.

The hide attribute is a list of the parts a look never shows. A hidden part takes no room and no input, and each switch below adds one part to the list. The corner is the square where the two bars meet, so this example scrolls both ways. The markers are the headings, and a hover over one opens the card.

Try it

17 px
html
<ax-scroll-view marker-selector="h2" hide="" track-click="page" style="height: 420px">
  <article>
    <h2>Introduction</h2>
    ...
  </article>
</ax-scroll-view>
css
ax-scroll-view {
  --ax-scrollbar-thickness: 17px;
}

ax-scroll-view > article {
  padding-right: var(--ax-scrollbar-vertical-size);
}

Introduction

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Setup

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Usage

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Styling

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Markers

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Reference

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Minimal

A whole look made of CSS on the default parts. It has a thumb that is 4 units thick at rest and widens to 10 while the bar is engaged, round markers, no track and no buttons. A unit is a fourteenth of the bar thickness, so the slider scales the whole bar. Each part of the bar is a shadow part, and ::part(thumb) matches the thumb of both bars. Host tokens such as --ax-scrollbar-thickness go on the element or an ancestor, and look inputs such as --ax-scrollbar-part-color and the shape properties of Surface go on the part.

A rule of the page on ::part(x) wins over the part's own CSS whatever its specificity, and it replaces the default value of every property it sets, in every state of x. The state rules of the default theme for that property stop applying, so the states that should differ are written again after the rule, in the order rest, current, on thumb, hover, pressed. That is why the thumb rule below is followed by thumb-engaged, thumb-hover and thumb-pressed. What a rule does not set keeps the default value.

The hot zone is the band along the outer edge of a bar where the pointer already counts as near the bar. Its depth is --ax-scrollbar-hotzone, and a percentage is a share of the thickness. While the slider is not at 0, the band is drawn in red over the example.

Try it

14 px
200 %
html
<ax-scroll-view class="minimal" hide="button" touch="bar" marker-selector="h2">
  ...
</ax-scroll-view>
css
.minimal {
    --ax-scrollbar-thickness: 14px;
    --minimal-unit: calc(var(--ax-scrollbar-thickness) / 14);
    --ax-scrollbar-padding: calc(var(--minimal-unit) * 2);
    --ax-scrollbar-thumb-min-length: calc(var(--minimal-unit) * 32);
    --ax-scrollbar-button-length: auto;
    --ax-scrollbar-hotzone: 200%;
    --ax-scrollbar-state-smoothing: 100ms;
    --ax-scrollbar-fade-in-duration: 0s;
    --ax-scrollbar-fade-out-duration: 0s;
    --ax-scrollbar-slide-distance: calc(var(--minimal-unit) * 3);
    --ax-scrollbar-card-delay: 0s;
    --ax-scrollbar-card-slide-duration: 250ms;
    --ax-scrollbar-card-slide-distance: 8px;
}
.minimal::part(thumb),
.minimal::part(button),
.minimal::part(track),
.minimal::part(corner),
.minimal::part(marker),
.minimal::part(card) {
    --ax-scrollbar-part-fill-clip: border-box;
}
css
.minimal::part(thumb) {
    --ax-radius: 50%;
    --ax-scrollbar-part-size: calc(var(--minimal-unit) * 4);
    --ax-scrollbar-part-color: rgba(0, 0, 0, 0.4);
    --ax-scrollbar-part-border-color: transparent;
}
.minimal::part(thumb-engaged) {
    --ax-scrollbar-part-size: calc(var(--minimal-unit) * 10);
}
.minimal::part(thumb-hover) {
    --ax-scrollbar-part-size: calc(var(--minimal-unit) * 10);
    --ax-scrollbar-part-color: rgba(0, 0, 0, 0.6);
}
.minimal::part(thumb-pressed) {
    --ax-scrollbar-part-size: calc(var(--minimal-unit) * 10);
    --ax-scrollbar-part-color: rgba(0, 0, 0, 0.7);
}
.minimal::part(button) {
    --ax-scrollbar-part-color: transparent;
    --ax-scrollbar-part-glyph-color: rgba(0, 0, 0, 0.5);
    --ax-scrollbar-glyph-width: 50%;
    --ax-scrollbar-glyph-height: 25%;
    --ax-scrollbar-glyph-thickness: 12.5%;
    --ax-scrollbar-glyph-offset: auto;
}
css
.minimal::part(button-hover) {
    --ax-scrollbar-part-glyph-color: rgba(0, 0, 0, 0.7);
}
.minimal::part(button-pressed) {
    --ax-scrollbar-part-glyph-color: rgba(0, 0, 0, 0.9);
}
.minimal::part(track),
.minimal::part(corner) {
    --ax-scrollbar-part-color: transparent;
}
.minimal::part(marker) {
    --ax-radius: 50%;
    --ax-scrollbar-part-size: calc(var(--minimal-unit) * 4);
    --ax-scrollbar-part-thickness: auto;
    --ax-scrollbar-part-color: rgba(0, 0, 0, 0.45);
}
.minimal::part(marker-on-thumb) {
    --ax-scrollbar-part-color: rgba(255, 255, 255, 0.9);
}
.minimal::part(marker-hover) {
    --ax-scrollbar-part-size: calc(var(--minimal-unit) * 7);
    --ax-scrollbar-part-color: rgba(0, 0, 0, 0.8);
}
css
.minimal::part(card) {
    --ax-radius: 4px;
    --ax-border-width: 0px;
    --ax-scrollbar-part-color: rgb(255, 255, 255);
    --ax-scrollbar-part-border-color: transparent;
    --elevation-depth: 8;
    --elevation-color: initial;
    color: rgb(34, 34, 34);
    font-family: inherit;
    font-size: 12px;
    padding: 6px 10px;
    max-width: 240px;
    transition: opacity 150ms linear, transform 250ms cubic-bezier(0.1, 0.9, 0.2, 1);
}
.minimal::part(card-exit) {
    transition-duration: 100ms;
}
css
@media (prefers-color-scheme: dark) {
    .minimal::part(thumb) {
        --ax-scrollbar-part-color: rgba(255, 255, 255, 0.4);
    }
    .minimal::part(thumb-hover) {
        --ax-scrollbar-part-color: rgba(255, 255, 255, 0.6);
    }
    .minimal::part(thumb-pressed) {
        --ax-scrollbar-part-color: rgba(255, 255, 255, 0.7);
    }
    .minimal::part(button) {
        --ax-scrollbar-part-glyph-color: rgba(255, 255, 255, 0.5);
    }
    .minimal::part(button-hover) {
        --ax-scrollbar-part-glyph-color: rgba(255, 255, 255, 0.7);
    }
    .minimal::part(button-pressed) {
        --ax-scrollbar-part-glyph-color: rgba(255, 255, 255, 0.9);
    }
    .minimal::part(marker) {
        --ax-scrollbar-part-color: rgba(255, 255, 255, 0.45);
    }
    .minimal::part(marker-on-thumb) {
        --ax-scrollbar-part-color: rgba(0, 0, 0, 0.9);
    }
    .minimal::part(marker-hover) {
        --ax-scrollbar-part-color: rgba(255, 255, 255, 0.8);
    }
    .minimal::part(card) {
        --ax-scrollbar-part-color: rgb(43, 43, 43);
        color: rgb(240, 240, 240);
    }
css
}

Acrylic track

The track blurs what scrolls under it. The swatches pass under the bar to show it.

Fluent

One layer is added by configuration. The rest is the default parts, styled with ::part().

Variants

Default hides the bar until the pointer is over it. Minimal is the thumb alone. Full never hides.

Markers

Dashes on the track mark the headings. Rest the pointer on one to read it.

Done

Scroll a little more.

Windows 11

The WinUI 3 scrollbar. It is a 2px line at rest that grows into a 6px thumb, an acrylic track and arrow buttons under the pointer. It is the default parts styled with ::part() plus one layer added by configuration. track-layers="scrollbar-fill acrylic" names the layers the track draws with, in paint order, and the acrylic layer comes from @ax-design/acrylic. Naming layers replaces the list, so scrollbar-fill stays in it. The swatches pass under the track so that you can see the acrylic blur them.

The three variants differ in attributes, and the stylesheet reads data-variant for what attributes cannot set. Default draws the track and the buttons only while the bar is engaged. Minimal is the thumb alone with touch="bar", shown by the pointer at the bar or by scrolling (indicator="near"). Full is the always expanded bar of Settings, Accessibility (indicator="pinned").

Try it

html
<ax-scroll-view class="win11"
  data-variant="default"
  track-layers="scrollbar-fill acrylic"
  hide="..." indicator="..." touch="...">
  ...
</ax-scroll-view>
js
import { registerAcrylicLayer } from '@ax-design/acrylic';
registerAcrylicLayer();

// What a variant sets as attributes; the rest is CSS.
const variants = {
  default: { hide: '', indicator: 'auto', touch: 'indicator' },
  minimal: { hide: 'button track corner', indicator: 'near', touch: 'bar' },
  full: { hide: '', indicator: 'pinned', touch: 'indicator' }
};

const view = document.querySelector('.win11');
const variant = variants['default'];
view.dataset.variant = 'default';
view.setAttribute('hide', variant.hide);
view.setAttribute('indicator', variant.indicator);
view.setAttribute('touch', variant.touch);
css
.win11 {
  --ax-scrollbar-thickness: 12px;
  --ax-scrollbar-button-length: 15px;
  --ax-scrollbar-expand-delay: 400ms;
  --ax-scrollbar-contract-delay: 500ms;
  /* the acrylic of the track */
  --acrylic-tint-color: #fcfcfc;
  --acrylic-tint-opacity: 0.85;
  --acrylic-blur: 30px;
}

/* 2px at rest, 6px engaged, hovered or pressed */
.win11::part(thumb) {
  --ax-radius: 50%;
  --ax-scrollbar-part-size: 2px;
  --ax-scrollbar-part-color: rgba(0, 0, 0, 0.447);
}
.win11::part(thumb-engaged),
.win11::part(thumb-hover),
.win11::part(thumb-pressed) {
  --ax-scrollbar-part-size: 6px;
}

/* invisible until the bar is engaged */
.win11::part(track) {
  --ax-radius: 6px;
  --ax-scrollbar-part-color: transparent;
  --ax-scrollbar-part-opacity: 0;
}
.win11::part(track-engaged) {
  --ax-scrollbar-part-opacity: 1;
}

Shape

Every part takes its outline from the Shape system: a corner shape and a radius, set on the part.

Bevel

A bevel of radius r cuts r off along both sides of a corner. Two opposite corners are cut, the others stay square.

Diamonds

The markers are squares with all four corners bevelled at the midpoint of their sides.

Glow

The card is lit by the elevation layer, in a magenta --elevation-color.

Night

Scroll on, and rest the pointer on a diamond.

Cyber Punk

Every part takes its outline from the Shape system of @ax-design/shape, which gives it a corner shape and a radius set on the part. Here the thumb, the track, the corner and the card use --ax-corner-shape: bevel, which cuts the radius off along both sides of a corner. Only two opposite corners are cut, and the other two are made square with the per-corner radii --ax-top-right-radius and --ax-bottom-left-radius.

The markers are diamonds. A bevel whose radius is 50% cuts each corner at the midpoint of its sides at any size, and a part thickness of auto keeps the marker square as it grows. The glow of the card is the elevation layer, where --elevation-depth is its reach and --elevation-color its colour, here magenta. There is no kit and no subclass, and the bar is the default parts.

Try it

4 px
#FF2BD6
35%
html
<ax-scroll-view class="cyber" snap touch="bar" hide="button" marker-selector="h2">
  ...
</ax-scroll-view>
css
.cyber {
    --ax-scrollbar-thickness: 16px;
    --ax-scrollbar-padding: 3px;
    --ax-scrollbar-thumb-min-length: 40px;
    --ax-scrollbar-button-length: 16px;
    --ax-scrollbar-state-smoothing: 40ms;
    --ax-scrollbar-slide-distance: 6px;
    --ax-scrollbar-fade-in-duration: 0s;
    --ax-scrollbar-fade-out-duration: 0s;
    --ax-scrollbar-hotzone: 200%;
    --ax-scrollbar-card-delay: 150ms;
    --ax-scrollbar-card-slide-duration: 250ms;
    --ax-scrollbar-card-slide-distance: 8px;
}
.cyber::part(thumb),
.cyber::part(button),
.cyber::part(track),
.cyber::part(corner),
.cyber::part(marker),
.cyber::part(card) {
    --ax-scrollbar-part-fill-clip: border-box;
}
css
.cyber::part(track) {
    --ax-scrollbar-part-color: rgba(0, 229, 255, 0.08);
    --ax-radius: 5px;
    --ax-corner-shape: bevel;
    --ax-top-right-radius: 0px;
    --ax-bottom-left-radius: 0px;
}
.cyber::part(thumb) {
    --ax-scrollbar-part-size: 8px;
    --ax-radius: 4px;
    --ax-corner-shape: bevel;
    --ax-top-right-radius: 0px;
    --ax-bottom-left-radius: 0px;
    --ax-border-width: 1px;
    --ax-scrollbar-part-color: #12a9c6;
    --ax-scrollbar-part-border-color: rgba(0, 229, 255, 0);
}
.cyber::part(thumb-hover) {
    --ax-scrollbar-part-size: 10px;
    --ax-scrollbar-part-color: #3df0ff;
    --ax-scrollbar-part-border-color: #b8fbff;
}
.cyber::part(thumb-pressed) {
    --ax-scrollbar-part-size: 10px;
    --ax-scrollbar-part-color: #ff2bd6;
    --ax-scrollbar-part-border-color: #ffd0f6;
css
}
.cyber::part(button) {
    --ax-radius: 3px;
    --ax-corner-shape: bevel;
    --ax-top-right-radius: 0px;
    --ax-bottom-left-radius: 0px;
    --ax-border-width: 1px;
    --ax-scrollbar-part-color: rgba(0, 229, 255, 0.1);
    --ax-scrollbar-part-border-color: rgba(0, 229, 255, 0.4);
    --ax-scrollbar-part-glyph-color: #12a9c6;
    --ax-scrollbar-glyph-width: 6px;
    --ax-scrollbar-glyph-height: 4px;
    --ax-scrollbar-glyph-thickness: 1.6px;
    --ax-scrollbar-glyph-offset: auto;
}
.cyber::part(button-hover) {
    --ax-scrollbar-part-color: rgba(0, 229, 255, 0.28);
    --ax-scrollbar-part-border-color: #3df0ff;
    --ax-scrollbar-part-glyph-color: #b8fbff;
}
.cyber::part(button-pressed) {
    --ax-scrollbar-part-color: #ff2bd6;
    --ax-scrollbar-part-border-color: #ffd0f6;
    --ax-scrollbar-part-glyph-color: #1a0014;
}
css
.cyber::part(corner) {
    --ax-radius: 5px;
    --ax-corner-shape: bevel;
    --ax-top-right-radius: 0px;
    --ax-bottom-left-radius: 0px;
    --ax-scrollbar-part-color: rgba(0, 229, 255, 0.1);
}
.cyber::part(marker) {
    --ax-scrollbar-part-size: 7px;
    --ax-scrollbar-part-thickness: auto;
    --ax-radius: 50%;
    --ax-corner-shape: bevel;
    --ax-scrollbar-part-color: #c4309f;
    --ax-scrollbar-part-opacity: 0.5;
}
.cyber::part(marker-engaged) {
    --ax-scrollbar-part-opacity: 1;
}
.cyber::part(marker-current) {
    --ax-scrollbar-part-size: 8px;
    --ax-scrollbar-part-color: #ff4fe0;
    --ax-scrollbar-part-opacity: 0.8;
}
.cyber::part(marker-on-thumb) {
    --ax-scrollbar-part-color: #04222b;
    --ax-scrollbar-part-opacity: 1;
css
}
.cyber::part(marker-hover) {
    --ax-scrollbar-part-size: 10px;
    --ax-scrollbar-part-color: #fff;
}
.cyber::part(marker-on-thumb marker-hover) {
    --ax-scrollbar-part-color: #fff;
}
.cyber::part(card) {
    --ax-scrollbar-part-color: #0c0a1f;
    --ax-scrollbar-part-border-color: #ff2bd6;
    --ax-border-width: 1px;
    --ax-radius: 8px;
    --ax-corner-shape: bevel;
    --ax-top-right-radius: 0px;
    --ax-bottom-left-radius: 0px;
    --elevation-depth: 8;
    color: #b8fbff;
    font-family: "Share Tech Mono", ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
    font-size: 12px;
    max-width: 240px;
    transition: opacity 150ms linear, transform 250ms cubic-bezier(0.1, 0.9, 0.2, 1);
    padding: 7px 13px 8px;
    --elevation-color: rgba(255, 43, 214, 0.35);
}
css
.cyber::part(card-exit) {
    transition-duration: 100ms;
}

Version 2.4

The list view keeps its place when the window is resized. Selected rows stay selected.

Version 2.3

Search results can be sorted by date or by name. The choice is remembered.

Table of values

name          region    count  unit    owner
north-depot   eastern    1204  crates  warehouse-a
south-depot   western     877  crates  warehouse-b
harbour-store coastal      56  pallets warehouse-c

Version 2.2

Fixed a case where the first row hid under the header after a scroll.

Version 2.1

New option to keep the header row visible while the body scrolls.

Earlier versions

Earlier notes are kept in the archive.

Windows XP

A kit replaces the parts themselves with classes of your own. Each kind has a contract class that draws nothing, and a kit class extends it and carries its look in its own shadow CSS, so nothing of the default look has to be switched off. The Windows XP (Luna) scrollbar ships with the package as such a kit. It has a raised thumb and arrow buttons with grip lines, an etched track, XP info tips and no fades.

Import it from the subpath, define it once and pick it with kit="luna". Its colours come from a scheme, which is a class on the scroll view or on any ancestor, because custom properties inherit into the parts. adoptLunaSchemes() adds the stylesheet of the seven schemes to the document once. Blue, Olive Green and Silver are the schemes of Windows XP.

Try it

js
import { register } from '@ax-design/scrollbar';
import { defineLunaKit, adoptLunaSchemes } from '@ax-design/scrollbar/kits/luna';

register();
defineLunaKit(); // defines the kit "luna"
adoptLunaSchemes(); // adds the scheme classes to the document
html
<div class="luna-blue">
  <ax-scroll-view kit="luna" snap touch="bar" vertical="always" style="height: 420px">
    ...
  </ax-scroll-view>
</div>

Version 2.4

The list view keeps its place when the window is resized. Selected rows stay selected.

Version 2.3

Search results can be sorted by date or by name. The choice is remembered.

Table of values

name          region    count  unit    owner
north-depot   eastern    1204  crates  warehouse-a
south-depot   western     877  crates  warehouse-b
harbour-store coastal      56  pallets warehouse-c

Version 2.2

Fixed a case where the first row hid under the header after a scroll.

Version 2.1

New option to keep the header row visible while the body scrolls.

Earlier versions

Earlier notes are kept in the archive.

Custom Luna scheme

A scheme is a class of custom properties, so a scheme of your own is a class that sets them. The tones are colours. --luna-accent, --luna-accent-light and --luna-accent-deep colour the thumb and the buttons, --luna-glyph the arrows and the grip, --luna-face the track and the corner, and --luna-disabled the disabled parts. The numbers are contrast values from 0 to 1, namely --luna-highlight, --luna-shade, --luna-gloss, --luna-depth, --luna-hot, --luna-press and --luna-etch.

Every colour of the parts is a tone with white and black laid over it by those numbers, so a new scheme needs its tones and the numbers of the scheme it resembles. Copy a class of lunaSchemes and change them. This one is green. The controls write onto the stage, over the class. The face of the thumb and the buttons is mostly --luna-accent-light, so that is the colour to change first. Gloss and depth set how much white and black the faces get.

Try it

#D3E6BF
0.182
0.196
css
.luna-moss {
  --luna-light: top-left; --luna-diagonal: 1; --luna-glint: 0;
  --luna-face: #e4e6d4; --luna-disabled: #f0f0e6;
  --luna-highlight: 1; --luna-shade: 0.72; --luna-gloss: 0.182;
  --luna-depth: 0.196; --luna-hot: 0.624; --luna-press: 0.675;
  --luna-etch: 0; --luna-relief: 1;
  --luna-accent: #a9c98b; --luna-accent-light: #d3e6bf;
  --luna-accent-deep: #4a8a1f; --luna-glyph: #3f5230;
}
html
<div class="luna-moss">
  <ax-scroll-view kit="luna" touch="bar">...</ax-scroll-view>
</div>

Introduction

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Setup

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Install

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Usage

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Notes

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Tips

Tricks

Reference

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Markers

With marker-selector, every matching element becomes a marker on the track, here each heading. Its id is the id of the element and its label is its text. The matches are read again when the content adds or removes nodes, and refreshMarkers() reads them after any other change. Press a marker to scroll it into the view, or rest the pointer on it and after --ax-scrollbar-card-delay a card opens with the labels.

Markers closer than --ax-scrollbar-marker-spacing share one mark, and its card lists all of them, one per line. Raise the spacing and the nearest headings join. marker-align decides where a marker scrolls to and sits, either its center or its start edge, and marker-axis puts the markers on the horizontal bar. Style the card on ::part(card) with real CSS for its text.

Try it

6 px
400 ms
html
<ax-scroll-view marker-selector="h2" marker-align="center" style="height: 420px">
  <h2>Introduction</h2>
  ...
</ax-scroll-view>
css
ax-scroll-view {
  --ax-scrollbar-marker-spacing: 6px;
  --ax-scrollbar-card-delay: 400ms;
}

ax-scroll-view::part(card) {
  --ax-scrollbar-part-color: #fff;
  --ax-scrollbar-part-border-color: #ccc;
  --ax-border-width: 1px;
  --elevation-depth: 8;
  color: #222;
  font: 12px/1.4 system-ui;
  padding: 6px 10px;
}

Heading A

Heading B

Heading C

no marker event yet

Markers from script

The markers property takes elements, a NodeList, marker objects or a mix, and it wins over marker-selector while it holds a list, even an empty one. null gives control back to the selector. A marker object has an id, a label, a position in scroll coordinates (or an element, whose start edge is the position), an axis and data of your own for the card of a kit. Use it for places that are not elements, such as search hits.

The component dispatches markerenter, markerleave and markeractivate on the element. They bubble, and detail has the marker of the mark and all the markers that share it. Cancel markeractivate with preventDefault() to do the jump yourself. The line under the view shows the last event.

Try it

js
const view = document.querySelector('ax-scroll-view');

// 'markers property' is in charge.
view.markers = [
  { id: 'start', label: 'Start', position: 0 },
  { id: 'a', label: 'Result 1 (4 hits)', position: 320, data: { hits: 4 } },
  { id: 'setup', label: 'Setup', element: document.getElementById('setup') },
  document.getElementById('summary')
];
view.markers = null; // back to marker-selector
js
view.addEventListener('markeractivate', (event) => {
  // Cancel the jump and scroll to it yourself.
  event.preventDefault();
  const { marker, markers } = event.detail;
  view.scrollTo({ top: marker.position, behavior: 'smooth' });
});

Introduction

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Setup

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Usage

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Styling

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Markers

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Reference

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Touch

touch="indicator", the default, shows the thumb alone as a panning indicator that takes no touch input.

touch="bar" makes the bars look and work on touch as they do with a mouse.

With the indicator the buttons, the track and the corner are left out while it shows and while it fades out, and the page scrolls under a finger as usual. With the bar, a finger that comes down on a bar or in its hot zone belongs to the bar and decides after 5px. Steeply along the bar it drags, relative to where it is, and across it nothing more happens. A quick tap glides to the tapped place or activates the marker under it.

The hovered marker and its card stay for --ax-scrollbar-touch-idle-delay after the finger lifts. Style the touch indicator on its own with the attribute the component writes, ax-scroll-view[last-indicator="touch"]::part(thumb), which stays set while the bars fade out. Try it with a finger. With a pointer this example behaves as it does with the mouse.

Try it

html
<ax-scroll-view touch="indicator" marker-selector="h2">
  ...
</ax-scroll-view>
css
ax-scroll-view {
  --ax-scrollbar-touch-idle-delay: 500ms;
}

/* the panning indicator, a thin thumb */
ax-scroll-view[last-indicator="touch"]::part(thumb) {
  --ax-scrollbar-part-size: 2px;
}

Text only

A

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

With a button

B

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Keyboard focus

The bars are for the pointer. They and the cards are aria-hidden, have no role and are not in the tab order, so the accessibility tree shows only the content. Give links or buttons for the same destinations as the markers. The element has no role or label of its own, so add them as for any element, as role="region" and aria-label below.

The viewport is a real scroll container and takes focus (tabindex="0") only while nothing inside it can. A text-only region stays scrollable by keys, and a region with links, buttons or fields adds no tab stop of its own, because focus goes into the content and the arrow and page keys scroll from there. The choice follows changes of the content and of the attributes that decide focus. Disable the button and the second view becomes a tab stop.

With forced colors (forced-colors: active) the overlay is hidden and the native scrollbars come back, and so they do when printing. With prefers-reduced-motion: reduce there is no glide and no thumb lag, parts change look at once, and the default card drops its movement and closes by itself. The Accessibility column of this page shows both.

Try it

html
<ax-scroll-view role="region" aria-label="Release notes" style="height: 420px">
  <article>...</article>
</ax-scroll-view>
css
/* Forced colors and print bring the native bars back, so keep
   no extra room for the overlay then. */
@media (forced-colors: active) {
  ax-scroll-view > article {
    padding-right: 0;
  }
}

/* Reduced motion: the component drops its own motion; drop yours. */
@media (prefers-reduced-motion: reduce) {
  ax-scroll-view::part(card) {
    transition: none;
  }
}

Slab

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Kit

The bars are an overlay. They never take room from the content, and they show while the pointer moves over the view.

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Thumb

Every part of a bar is a shadow part: you style it with ::part(), or replace it with a kit.

Scrolling stays native: the wheel, the touch and the keys scroll the view, and find in page reaches the content.

Writing a kit

A kit is a named set of part classes. defineScrollbarKit(name, kit) takes a class for any of thumb, button, track, trackPiece, corner, marker and card, and a default for hide. Each class extends the contract class of its kind, which draws nothing (AxScrollbarThumb and the others), or the default class of the kind (AxScrollbarDefaultThumb), and carries its look in its own shadow root as static styles. The bars keep deciding where a part sits along the bar and how long it is. The kinds a kit leaves out are the default classes.

A class reads its state in the CSS as attributes of the host, such as :host([hover]), :host([pressed]), :host([axis="horizontal"]), :host([direction="start"]), :host([disabled]) and :host([rtl]), or in script as partState with stateChanged(). The thumb below is one class, and the colour and the inset are custom properties that inherit into it. An author's ::part() rules still win over the CSS of a kit.

A class that does not extend the contract class of its kind throws a TypeError before anything is defined, so a bad kit leaves nothing behind, and defining a name twice throws as well. In TypeScript the fields of ScrollbarKit are typed as PartClass, so a wrong class does not compile. The line under the view is the error the page got when it defined a kit with a thumb that is not a thumb.

Try it

#888888
3 px
js
import { AxScrollbarThumb, defineScrollbarKit } from '@ax-design/scrollbar';

class SlabThumb extends AxScrollbarThumb {
  static styles = `
    :host { background: rgb(136, 136, 136); margin: 0 3px; }
    :host([axis="horizontal"]) { margin: 3px 0; }
    :host([pressed]) { background: rgb(0, 120, 215); }
  `;
}

defineScrollbarKit('slab', { thumb: SlabThumb, hide: '' });
html
<ax-scroll-view kit="slab" style="height: 420px">...</ax-scroll-view>
js
import { isPartClass } from '@ax-design/scrollbar';

isPartClass(SlabThumb, 'thumb'); // true

// The type check: not a thumb.
defineScrollbarKit('bad', { thumb: class {} });
// TypeError, and nothing is defined

Section 1

Scrolling stays native, so the keys, the wheel and find in page work as usual.

Section 2

Scrolling stays native, so the keys, the wheel and find in page work as usual.

Section 3

Scrolling stays native, so the keys, the wheel and find in page work as usual.

The preview in forced colors with a dark theme.The preview in forced colors with a light theme.

Accessibility

Keyboard: The viewport of ax-scroll-view takes focus with Tab while nothing inside it can take focus, and the arrow, Page Up, Page Down, Home and End keys scroll it. A view with links, buttons or fields adds no tab stop of its own: focus goes into the content, and the keys scroll from there. The bars are not in the tab order: markers are for the pointer, so give links or buttons for the same places.

Screen readers: The bars and the cards are hidden from assistive technology. Name the view as a region, as in the code, so the content has a name when it takes focus.

High contrast (forced colors): The overlay is not drawn, and the native scrollbars of the browser come back, so the bars stay visible.

Reduced motion: The bars take their end state at once: no glide, no lag of the thumb, and the card does not move. The card closes by itself after a short delay.

Try it: in Chrome or Edge, open DevTools, then Rendering, and emulate the CSS media features prefers-reduced-motion, forced-colors or prefers-reduced-transparency. Or turn on the setting of your system: Show animations, Contrast themes and Transparency effects on Windows; Reduce motion, Increase contrast and Reduce transparency on macOS.

Try it

html
<ax-scroll-view role="region" aria-label="Article" style="height: 420px">
  <article>...</article>
</ax-scroll-view>

Reference

vertical attribute

auto | always | hidden, default auto

auto: the bar shows while there is something to scroll. always: it always shows, disabled when nothing scrolls. hidden: it never shows.

horizontal attribute

auto | always | hidden, default auto

As vertical, for the horizontal bar.

hide attribute

list of parts, default (empty)

The parts a look never shows, as a space-separated list of thumb, button, track (with its pieces), track-piece, corner, marker and card. A hidden part takes no room and no input. Unknown names are ignored. Even an empty value overrides the default, and a kit gives its own default.

indicator attribute

auto | near | pinned, default auto

auto: the bars show on every move over the scroller. near: only for a pointer at a bar, or while scrolling (always so on ax-scrollbar). pinned: the bars always show.

track-click attribute

page | jump, default page

A press on the track pages toward the pointer, repeating while held, or with jump scrolls to the pointer.

touch attribute

indicator | bar, default indicator

What the bars are on touch. indicator shows the thumb alone as a panning indicator that takes no input and leaves out the buttons, the track and the corner. bar makes the bars look and work as they do with a mouse, so a finger on a bar drags it and a tap on the track glides to the tapped place.

snap attribute

boolean, default false

Thumb edges and markers on whole device pixels, for looks that reproduce a desktop control. Leave it off for vector looks.

suspended attribute

boolean, default false

Fades the bars out and makes them ignore input, for a lightbox over the page. Remove it and they show again.

kit attribute

kit name

Builds the parts with the kit of that name, made with defineScrollbarKit() or defineLunaKit(). A change of kit rebuilds the bars, and a kit may be defined after the element.

marker-selector attribute

CSS selector

Makes a marker for each matching element, with the id of the element and its text as the label.

marker-axis attribute

vertical | horizontal, default vertical

The axis of the markers made from elements.

marker-align attribute

center | start, default center

Where a marker scrolls to, and where it sits on the track.

thumb-layers attribute

layer names

The layers a kind of part draws with, in paint order. Likewise button-layers, track-layers, track-piece-layers, corner-layers, marker-layers and card-layers. Defaults: scrollbar-fill, scrollbar-fill scrollbar-glyph for a button, scrollbar-fill elevation for a card. Naming layers replaces the list.

indicator-state attribute

none | mouse | touch

Written by the component, for you to select on: the indicator that shows.

last-indicator attribute

mouse | touch

Written by the component: the indicator shown, or shown last while the bars fade out. Style per indicator with this, so the look stays during the fade.

vertical-state attribute

list of states

Written by the component, also horizontal-state: a list of visible, disabled, hover, near, engaged and pressed. Select with ~=.

--ax-scrollbar-thickness property

<length>, default 17px

The thickness of a bar. The default look is sized from it: the thumb and the hovered marker fill the bar, the rest scales with thickness / 17px.

--ax-scrollbar-padding property

<length>, default 0px

Space between the edges of the bar and its buttons and track.

--ax-scrollbar-thumb-min-length property

<length>, default 48px

The shortest thumb (17px while the touch indicator shows).

--ax-scrollbar-button-length property

<length> | auto, default 33/17 of the thickness

The length of a button along the axis; auto is a square. Not registered: unset, it is the proportion.

--ax-scrollbar-hotzone property

<length> | <percentage>, default 0px

The depth of the hot zone inside the outer edge of a bar; percentages are of the thickness. A bar with a hot zone shows when the pointer comes near it, and takes the near state.

--ax-scrollbar-line property

<length>, default 40px

The scroll of a button press. A track page is the view minus one line.

--ax-scrollbar-idle-delay property

<time>, default 3s

How long the mouse indicator lingers before it fades out.

--ax-scrollbar-touch-idle-delay property

<time>, default 500ms

How long the touch indicator, its hovered marker and its card linger.

--ax-scrollbar-scroll-smoothing property

<time>, default 50ms

The glide of the scrolls the bars start. A wheel, touch, key or script scroll stops it.

--ax-scrollbar-thumb-smoothing property

<time>, default 100ms

The lag of the drawn thumb, track pieces and markers behind the scroll.

--ax-scrollbar-repeat-delay property

<time>, default 250ms

The wait before a held press repeats. --ax-scrollbar-repeat-interval (50ms) is the time between repeats.

--ax-scrollbar-expand-delay property

<time>, default 0s

How long the bar must be engaged before parts take engaged. --ax-scrollbar-contract-delay (0s) is how long it must be idle before they leave it.

--ax-scrollbar-state-smoothing property

<time>, default 0s

Colour and size change on hover, press and engaged. A time constant: 63% of the way after one.

--ax-scrollbar-fade-smoothing property

<time>, default 75ms

The fade of the parts when the bars show or hide.

--ax-scrollbar-fade-in-duration property

<time>, default 250ms

A linear fade in, and --ax-scrollbar-fade-out-duration (167ms) out; a card fades with them. 0s leaves it to the fade smoothing.

--ax-scrollbar-slide-smoothing property

<time>, default 160ms

The slide of parts in from the outer edge.

--ax-scrollbar-slide-distance property

<length>, default 0px

How far a hidden part sits toward the outer edge; negative slides from the content side.

--ax-scrollbar-marker-spacing property

<length>, default 6px

Markers closer than this share one mark.

--ax-scrollbar-marker-reach property

<length>, default 12px

How far from the center of a marker the pointer still hits it.

--ax-scrollbar-card-gap property

<length>, default 12px

The distance between a card and the bar.

--ax-scrollbar-card-delay property

<time>, default 400ms

The wait on a marker before the first card opens.

--ax-scrollbar-card-slide-duration property

<time>, default 367ms

The slide of an opening card. --ax-scrollbar-card-slide-distance (12px) is how far it slides; 0px for none.

--ax-scrollbar-vertical-size property

<length>, default 0px

Written by the component on the host, read-only, with --ax-scrollbar-horizontal-size: the thickness of the bar that is shown, 0px while it is not. Use it to keep the content clear of the bar.

--ax-scrollbar-z-index property

<integer>, default 2147483000

The stacking of ax-scrollbar. Not registered, read with var(); a page with modals can put the bars under them.

::part(thumb) property

shadow part

The part you drag. The others are scrollbar, scrollbar-vertical, scrollbar-horizontal, button (-start, -end), track, track-piece (-start, -end), corner, marker, card and card-content, and viewport and content on ax-scroll-view. ::part(thumb) matches both axes, ::part(thumb-vertical) one, and a list matches a part with all the names: ::part(marker-on-thumb marker-hover).

::part(thumb-hover) property

state part

States of every kind, as <kind>-hover, -pressed, -engaged and -disabled, plus marker-current, marker-on-thumb and for the card card-enter, card-open, card-exit, card-forward, card-backward, card-first and card-swap. Also set as attributes on the part element: hover, pressed, engaged, disabled, rtl, on-thumb, current, axis, direction.

--ax-scrollbar-part-color property

<color>, default transparent

The fill colour of a part. Set it on ::part(), where it is registered, typed and not inherited. --ax-scrollbar-part-border-color (transparent) is its border and --ax-scrollbar-part-glyph-color (transparent) the arrow of a button.

--ax-scrollbar-part-size property

<length>, default 0px

The size across the bar: the thickness of the thumb, the width of a marker. --ax-scrollbar-part-thickness (auto, a dot) is the size of a marker along the bar.

--ax-scrollbar-part-opacity property

<number>, default 1

The opacity of a part while the bars are shown.

--ax-scrollbar-part-align property

<number>, default 0.5

Where the thumb sits across the bar: 0 the inner edge, 1 the outer edge. Not animated.

--ax-scrollbar-part-fill-clip property

border-box | padding-box, default border-box

Whether the fill stops at the inner edge of the border.

--ax-scrollbar-part-lines property

<number>, default 2

The lines a label of the card may take.

--ax-scrollbar-glyph-shape property

chevron | triangle

The arrow of a button, set on ::part(button) with --ax-scrollbar-part-glyph-scale (1), --ax-scrollbar-glyph-width, -height, -thickness, -offset and -corner-radius.

markers property

ScrollMarker[] | null, default null

Markers set from script: elements, a NodeList, marker objects (id, label, axis, position, element, data), or a mix. They win over marker-selector while they hold a list; null gives control back to the selector.

viewport property

HTMLElement

ax-scroll-view only: the element that really scrolls. The view has the scroll API of an element, scrollTop, scrollLeft, scrollHeight, scrollWidth, scrollTo(), scrollBy() and scroll(), acting on it, and dispatches scroll and scrollend itself.

refresh() method

() => void

Lays the bars out on the next frame. A change of a token applies without it; call it for other changes you made from script.

refreshMarkers() method

() => void

Collects and measures the markers again on the next frame, after a change the component cannot see.

register() method

() => void

Defines ax-scroll-view, ax-scrollbar, the default parts, the scrollbar-fill, scrollbar-glyph and elevation layers and ax-surface. Safe to call more than once.

defineScrollbarKit() method

(name: string, kit: ScrollbarKit) => void

Defines a kit by name, used with the kit attribute. A class that does not extend the contract class of its kind throws a TypeError; defining a name twice throws. hasScrollbarKit(name) looks one up.

AxScrollbarThumb method

contract class

Contract classes draw nothing: AxScrollbarThumb, AxScrollbarButton, AxScrollbarTrack, AxScrollbarTrackPiece, AxScrollbarCorner, AxScrollbarMarker and AxScrollbarCard. Default classes are AxScrollbarDefault plus the kind, the Windows 8 look. AxScrollbarPart is the base of all, and isPartClass(cls, kind) tests a class.

static styles property

string

On a part class: CSS in the shadow root of the part, reading its state as :host([hover]), :host([pressed]), :host([axis="vertical"]), :host([direction="start"]), :host([disabled]) and :host([rtl]). partState and stateChanged(prev, next) are the same in script.

defineLunaKit() method

() => void

From @ax-design/scrollbar/kits/luna: defines the kit "luna", the Windows XP scrollbar. adoptLunaSchemes(root = document) adopts the stylesheet of the schemes once into a document or a shadow root, and lunaSchemes is that stylesheet as a string.

.luna-blue property

scheme class

The schemes: luna-blue, luna-olive, luna-silver, luna-purple, luna-orange, luna-pink and luna-gold, on the scroll view or any ancestor. A scheme sets the tones --luna-accent, -accent-light, -accent-deep, --luna-glyph, --luna-face and --luna-disabled, the numbers --luna-highlight, -shade, -gloss, -depth, -hot, -press and -etch, --luna-light (top-left or top), and --luna-diagonal and --luna-glint (0 or 1).

markeractivate event

CustomEvent

A press on a marker released with under 4px of movement. Cancelable: preventDefault() stops the jump to the marker. detail is { marker, markers }.

markerenter event

CustomEvent

The pointer moves onto a marker. detail is { marker, markers }, the markers sharing the mark.

markerleave event

CustomEvent

The pointer leaves the last marker it was on; moving straight to another fires markerenter only.