IX Surfaces

Star on GitHubFeedback
IX SurfacesNative HTML and a hookot-scrollbar

Scrollbar

A scrollbar as a ruler: a hairline, a mark where each section begins, and an ink thumb as long as the view.

Report an issue with Scrollbar

Example

Overture

Line 1 of the brief.

Line 2 of the brief.

Line 3 of the brief.

Line 4 of the brief.

Line 5 of the brief.

Line 6 of the brief.

Notes

Line 7 of the brief.

Line 8 of the brief.

Line 9 of the brief.

Line 10 of the brief.

Line 11 of the brief.

Line 12 of the brief.

Close

Line 13 of the brief.

Line 14 of the brief.

Line 15 of the brief.

Line 16 of the brief.

Line 17 of the brief.

Line 18 of the brief.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Rest
Pointed at
Held
Numeral
Pointed at
Held
Leaves

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

More leaves than fit

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Everything is type.

The library gives the page two typefaces, and asks it to hold still.

Space does the layout. Type is the only ornament.

One colour marks where you are, and only that.

What you choose is set in the italic; the rest is roman.

Nothing moves unless you do.

A rule appears where there is more, and leaves when there isn't.

The quieter the page, the more each mark on it says.

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.

npx shadcn@latest add https://thedirectors.agency/ui/r/scrollbar.json

Usage

scrollbar-example.tsx
import type { CSSProperties } from "react"import { Scrollbar } from "@/components/ui/scrollbar"const sections = [{ id: "overture", num: "I", name: "Overture" },{ id: "notes", num: "II", name: "Notes" },{ id: "close", num: "III", name: "Close" },]const lines = Array.from({ length: 18 }, (_, i) => `Line ${i + 1} of the brief.`)const passage = ["Everything is type.","The library gives the page two typefaces, and asks it to hold still.","Space does the layout. Type is the only ornament.","One colour marks where you are, and only that.","What you choose is set in the italic; the rest is roman.","Nothing moves unless you do.","A rule appears where there is more, and leaves when there isn't.","The quieter the page, the more each mark on it says.",]/** Boxes that scroll, each with a rail as its last child: the ruler (its sections are the headings inside), the numeral and the leaves. */export default function Example() {return (<div className="grid items-start gap-x-(--ot-space-8) gap-y-(--ot-space-7) md:grid-cols-2"><div className="relative h-56 overflow-auto md:col-span-2"><div className="grid max-w-xl gap-[var(--ot-space-4)] pe-[var(--ot-space-6)]"><h3 id="overture" className="ot-label">Overture</h3>{lines.slice(0, 6).map((l) => <p key={l}>{l}</p>)}<h3 id="notes" className="ot-label">Notes</h3>{lines.slice(6, 12).map((l) => <p key={l}>{l}</p>)}<h3 id="close" className="ot-label">Close</h3>{lines.slice(12).map((l) => <p key={l}>{l}</p>)}</div><Scrollbar sections={sections} /></div><div className="relative h-56 overflow-auto" tabIndex={0} role="region" aria-label="The idea, with the numeral rail"><div className="grid gap-[var(--ot-space-4)] pe-[var(--ot-space-8)]">{passage.concat(passage).map((l, i) => <p key={i}>{l}</p>)}</div><Scrollbar variant="numeral" /></div><div className="relative h-56 overflow-auto" tabIndex={0} role="region" aria-label="The idea, with the leaves rail"><div className="grid gap-[var(--ot-space-4)] pe-[var(--ot-space-7)]">{passage.concat(passage, passage).map((l, i) => <p key={i}>{l}</p>)}</div><Scrollbar variant="leaves" /></div></div>)}const pinned = [{ id: "a", num: "II", name: "Notes", at: 0.2 },{ id: "b", num: "III", name: "Close", at: 0.45 },{ id: "c", num: "IV", name: "Credits", at: 0.75 },]function Rail({ force, variant }: { force?: string; variant?: "numeral" }) {return (<div className="relative h-40 w-16"><Scrollbar data-force={force} variant={variant} sections={variant ? undefined : pinned} style={{ "--view": variant ? 0.125 : 0.3, "--p": 0.35 } as CSSProperties} /></div>)}/** Leaves are laid by the script, so their states are live boxes: scroll them. */function Leaves({ label, times }: { label: string; times: number }) {return (<div className="relative h-40 w-56 overflow-auto" tabIndex={0} role="region" aria-label={label}><div className="grid gap-[var(--ot-space-3)] pe-[var(--ot-space-6)]">{Array.from({ length: times }, () => passage).flat().map((l, i) => <p key={i}>{l}</p>)}</div><Scrollbar variant="leaves" /></div>)}

Contract

  • Underneath: hook (useScrollbar).
  • Anatomy: <div class="ot-scrollbar" aria-hidden="true"><span class="ot-scrollbar-thumb"></span></div> placed anywhere inside a scroller (it finds the nearest one around it: a cmdk list keeps it inside its sizer). data-axis="x" runs it along the bottom of a box that scrolls sideways (right-to-left reverses it: --dir). data-variant="page" fixes it to the window's inline end instead, and takes one <span class="ot-scrollbar-mark" data-num data-name style="--at; --i"> per section. data-variant="numeral" and data-variant="leaves" are inner rails with another look (down the side only); leaves adds <span class="ot-scrollbar-leaves">, which the script fills with one <span class="ot-scrollbar-leaf" data-leaf="3/7"> per disc.
  • Script sets --view (the share in view, with a thumb of at least min px), --max (how far the box scrolls), --dir, data-idle when there's nothing to scroll, then data-ready; it then marks the host data-scrollbar-host (="pin" when it also had to be given position: relative) and, while there is something to scroll, data-lenis-prevent. It re-measures on resize and on any change inside the box, including its content growing or a disclosure opening; scrolling uses the saved measurements. It turns on only for a fine pointer with scroll timelines; otherwise the thin native stroke stays.
  • Page scrolling optionally goes through window.__otScrollTo(target: number, immediate: boolean). Install this adapter to send page marks and dragging through a smooth scroller; dragging passes true, so it stops any active easing and follows the pointer at once. Without it the page uses native scrolling. Inner rails always stay native. The item has no site or smooth-scroller dependency; the site exposes scrollPageTo through this hook and removes it on unmount.
  • The site has one page rail, in the root layout (PageRail, which is ReadingTrail variant="rail"): elements with an id and data-rail="Name" become its marks, numbered in order.
  • A ruler: a hairline and an ink thumb as long as the view. Inside a scroller, the rail is pinned by the scroller's own timeline (animation-timeline: scroll(nearest), translating by --max), so it stays on the edge with no script running while you scroll. The thumb rides the same timeline. On the page, a mark shows where each section begins, and the current one is ink (data-now).
  • Pointing sketches: pointing at a scroller darkens its rail; pointing at the rail thickens the thumb, and on the page the numbers arrive down the rail in turn, with a mark's name when you point at it. Holding the thumb (data-dragging) inks it in the accent, because it marks where you are.
  • Pointer only: pressing a page mark goes to its section, pressing the rail centres the thumb there, and the thumb drags. Pressing doesn't move focus, so an open list stays open. The keyboard scrolls as always, which is why the rail is hidden from assistive technology.
  • Down the side of a box, a mark's number leans into the box as its mark nears the top or bottom (by --at), so the first is never cut off by the box's top edge; the page's rail is tall enough not to.
  • Numeral, after "the silence that heals" (14 / 08), where numbers are the data: the thumb is a figure, how far down you are from 00 to 100 (decimal-leading-zero, tabular, --ot-p), riding the hairline on a 0.375rem ink dot. It counts through every number as the box scrolls: a counter on a registered --ot-scrollbar-read that the scroller's own timeline drives, so it keeps time with smooth scrolling and needs no script while you scroll. At rest the figure is pencil; pointing inks it and swells the dot; holding (or dragging it) puts both in the accent. The rail is 3.25rem wide. Section marks work as on the ruler.
  • Leaves, after the "Less but better" calendar (gone, today, to come): no thumb, but a disc for every screenful (a leaf) of the box, stacked down from the top of the rail, 0.625rem on a 1.125rem step. Those you've read are ink (data-read), the one you're on is the one accent (data-now), those to come are hairline rings. When they won't fit down the rail, a disc holds as many leaves as it must. Script lays them on resize and change and marks them on scroll. Pointing at a disc swells it and names its place ("3/7"); pressing it turns the box to that leaf (smooth, at once with reduced motion).
  • Forced colours: the rail keeps its own colours (forced-color-adjust: none), remapped to CanvasText, GrayText and Highlight, because the thumb, the dot and the discs are drawn as backgrounds.
  • Once the rail is running, the host's own bar is gone (scrollbar-width: none on [data-scrollbar-host]). Without the rail (touch, or before the script) every scroller gets a thin stroke on a clear track (scrollbar-color from :root, scrollbar-width: thin) that darkens from rule to pencil when pointed at; Safari, which lacks scrollbar-color, gets a hairline thumb that thickens.

Props

Props of Scrollbar
PropTypeDefaultWhat it does
variant"inner" | "page" | "numeral" | "leaves""inner"Inner pins to the edge of the scroll container it sits in (it finds the nearest scroller around it). Page fixes it to the window and measures the document. numeral is an inner rail whose thumb is a figure, how far down you are from 00 to 100, riding the hairline on a dot; it counts as you scroll, inks when pointed at and takes the accent while held. leaves is an inner rail with no thumb: a disc for every screenful, read ones ink, the one you're on the accent, those to come rings; point at one for its place (3/7), press it to turn there. Numeral and leaves run down the side only.
axis"y" | "x""y"x is the same rail along the bottom of a box that scrolls sideways (a wide table). Inner only.
minnumber24 (page: 40)The thumb is never shorter than this many pixels, so it can be held.
sections{ id, num, name }[]Marks on the rail that scroll to the element with that id. Point at the rail and their numbers arrive; point at a mark to read its name.
window.__otScrollTo(target: number, immediate: boolean) => voidOptional page-scroll adapter for a smooth scroller. Page marks and dragging call it instead of window.scrollTo; dragging passes immediate: true to stop active easing. Inner rails stay native. Remove the hook when the scroller unmounts.
AccessibilitynoteThe rail is hidden from assistive technology and the browser's own bar is hidden only for a fine pointer, and only once the rail is running; keyboard scrolling is unchanged. Offer the sections elsewhere too, such as a table of contents.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
ScrollbarPaul Rand dimension linesThe page as a ruler, marked at each movement
Numeral scrollbar"the silence that heals" (14 / 08)The thumb is a figure: how far down you are
Leaves scrollbar"Less but better" calendarA disc a screenful: read in ink, here in the accent, to come in rings

How it moves

How it moves
ComponentArticulationWhat moves
ScrollbarArpeggio, then inkThe numbers arrive down the rail; held, the thumb takes the accent
Numeral scrollbarCountThe figure counts through every number as you scroll; held, it takes the accent
Leaves scrollbarInkThe disc you reach takes the accent and the one you leave sets to ink