IX Surfaces

Star on GitHubFeedback
IX SurfacesNative HTMLot-scroll

Scroll area

A rule appears at an edge only while there's more beyond it, and leaves when you reach the end.

Report an issue with Scroll area

Example

0.4The library

  • Every component in one stylesheet, fenced for the library.
  • Right-to-left support for crumbs, checkboxes and fields.

0.3Colour

  • Four type pairs and five colour schemes.

0.2Motion

  • Motion that answers the hand, and rests.
  • The overture: text that parts around the pause.

0.1Posters

  • Twenty posters pinned to a wall.
  • One rule: ours in roman, yours in italic.

0.0A name

  • A name, taken from music.

Everything is type. The library gives an interface two typefaces, one accent and a great deal of space to lean on, and takes every picture away.

Space does the layout. Where a box would say that things belong together, the gap between them says it first, and more quietly.

Type is the only ornament. A word can be large or small, roman or italic, ink or pencil, and that is all the decoration there is.

One colour marks where you are. If two things on the page were in the accent, there would be two places to be.

Nothing moves unless you do. The page waits, the way a room waits for someone to speak.

  • pppAs soft as it can be played
  • ppVery soft
  • pSoft
  • mpModerately soft
  • mfModerately loud
  • fLoud
  • ffVery loud
  • fffAs loud as it can be played
  • fpLoud, then at once soft
  • sfzA sudden weight on one note
  • cresc.Growing louder
  • dim.Growing softer
Sideways

0.4The library

Every component in one stylesheet, fenced for the library.

Right-to-left support for crumbs, checkboxes and fields.

0.3Colour

Four type pairs and five colour schemes.

0.2Motion

Motion that answers the hand, and rests.

The overture: text that parts around the pause.

0.1Posters

Twenty posters pinned to a wall.

One rule: ours in roman, yours in italic.

0.0A name

A name, taken from music.

Catchword

Everything is type. The library gives an interface two typefaces, one accent and a great deal of space to lean on, and takes every picture away.

Space does the layout. Where a box would say that things belong together, the gap between them says it first, and more quietly.

Type is the only ornament. A word can be large or small, roman or italic, ink or pencil, and that is all the decoration there is.

One colour marks where you are. If two things on the page were in the accent, there would be two places to be.

Nothing moves unless you do. The page waits, the way a room waits for someone to speak.

Catchword, right to left

صفر ديسيبل هو أهدأ صوت يمكن أن يسمعه الإنسان.

المسافة ترتّب الصفحة، والحرف هو الزينة الوحيدة.

لون واحد يدلّ على مكانك، ولا شيء غيره.

لا شيء يتحرّك ما لم تتحرّك أنت.

Wheel
  • pppAs soft as it can be played
  • ppVery soft
  • pSoft
  • mpModerately soft
  • mfModerately loud
  • fLoud
  • ffVery loud
  • fffAs loud as it can be played
Wheel, right to left
  • pppAs soft as it can be played
  • ppVery soft
  • pSoft
  • mpModerately soft
  • mfModerately loud
  • fLoud
  • ffVery loud
  • fffAs loud as it can be played
Without sections
  • Every component in one stylesheet, fenced for the library.
  • Right-to-left support for crumbs, checkboxes and fields.
  • Four type pairs and five colour schemes.
  • Motion that answers the hand, and rests.
  • The overture: text that parts around the pause.
  • Twenty posters pinned to a wall.
  • One rule: ours in roman, yours in italic.
  • A name, taken from music.

Install

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

npx shadcn@latest add https://thedirectors.agency/ui/r/scroll-area.json

Usage

scroll-area-example.tsx
import { ScrollArea } from "@/components/ui/scroll-area"const releases = [{ version: "0.4", name: "The library", notes: ["Every component in one stylesheet, fenced for the library.", "Right-to-left support for crumbs, checkboxes and fields."] },{ version: "0.3", name: "Colour", notes: ["Four type pairs and five colour schemes."] },{ version: "0.2", name: "Motion", notes: ["Motion that answers the hand, and rests.", "The overture: text that parts around the pause."] },{ version: "0.1", name: "Posters", notes: ["Twenty posters pinned to a wall.", "One rule: ours in roman, yours in italic."] },{ version: "0.0", name: "A name", notes: ["A name, taken from music."] },]const idea = ["Everything is type. The library gives an interface two typefaces, one accent and a great deal of space to lean on, and takes every picture away.","Space does the layout. Where a box would say that things belong together, the gap between them says it first, and more quietly.","Type is the only ornament. A word can be large or small, roman or italic, ink or pencil, and that is all the decoration there is.","One colour marks where you are. If two things on the page were in the accent, there would be two places to be.","Nothing moves unless you do. The page waits, the way a room waits for someone to speak.",]const dynamics = [["ppp", "As soft as it can be played"],["pp", "Very soft"],["p", "Soft"],["mp", "Moderately soft"],["mf", "Moderately loud"],["f", "Loud"],["ff", "Very loud"],["fff", "As loud as it can be played"],["fp", "Loud, then at once soft"],["sfz", "A sudden weight on one note"],["cresc.", "Growing louder"],["dim.", "Growing softer"],]function Idea() {return (<div className="grid gap-[var(--ot-space-4)] py-[var(--ot-space-3)] pe-[var(--ot-space-6)]">{idea.map((t) => <p key={t}>{t}</p>)}</div>)}function Dynamics({ rows = dynamics }: { rows?: string[][] }) {return (<ul className="grid gap-[var(--ot-space-2)] py-[var(--ot-space-6)]">{rows.map(([mark, meaning]) => (<li key={mark}><span className="inline-block w-14 tabular-nums">{mark}</span>{meaning}</li>))}</ul>)}/** Ruled: each release is a section, and a mark on the rail. Catchword: the first word below waits on the foot rule. Wheel: the dynamics turn on an arc. */export default function Example() {return (<div className="grid items-start gap-x-(--ot-space-8) gap-y-(--ot-space-7) md:grid-cols-2"><ScrollAreaaria-label="Release notes"sections={releases.map((r) => ({ id: `release-${r.version}`, num: r.version, name: r.name }))}className="max-h-56 max-w-xl md:col-span-2"><div className="grid gap-[var(--ot-space-5)] py-[var(--ot-space-4)] pe-[var(--ot-space-6)]">{releases.map((r) => (<section key={r.version} id={`release-${r.version}`} aria-labelledby={`release-${r.version}-h`}><h3 id={`release-${r.version}-h`}><span className="inline-block w-10 tabular-nums">{r.version}</span>{r.name}</h3><ul className="grid gap-1 ps-10">{r.notes.map((n) => (<li key={n}>{n}</li>))}</ul></section>))}</div></ScrollArea><ScrollArea variant="catchword" aria-label="The idea" className="max-h-56"><Idea /></ScrollArea><ScrollArea variant="wheel" aria-label="Dynamics" className="max-h-56"><Dynamics /></ScrollArea></div>)}

Contract

  • Underneath: native overflow, with the scrollbar.
  • Anatomy: <div class="ot-scroll" data-slot="scroll-area" data-variant="ruled | catchword | wheel" tabindex="0" role="region" aria-label> holding two rails last (catchword puts <div class="ot-scroll-foot" aria-hidden="true"><span class="ot-scroll-catchword"></span></div> before them): a .ot-scrollbar down the side and a .ot-scrollbar[data-axis="x"] along the bottom, each hidden until there's something to scroll its way. The rail also sets data-lenis-prevent on it while there is something to scroll down, so smooth scrolling leaves it alone.
  • It wears the page's rail, not a lighter one: 1.5rem wide with the hairline 0.5rem in (--line), an ink thumb as long as the view. sections ({ id, num, name }[], one per element inside with that id) put a tick on both rails: pointing at the rail brings their numbers down it in turn, pointing at a tick names it, pressing one scrolls there (at once with reduced motion), and the section in the middle of the view is ink. A section too near the end to reach the top keeps its own place inside the thumb's last stretch, so the last ticks never pile up. The box is short, so each number leans into it as its tick nears an end (by --at); sideways, the ticks rise from the rail with their numbers above. With no sections it is the bare rail.
  • A rule appears at an edge only while there's more beyond it. Pure CSS: paper backgrounds that scroll (background-attachment: local) cover rules that don't.
  • Catchword, after the printer's catchword (the first word of the next page set alone at the foot of this one, so the reader goes on): while there's more below, a foot line of paper (.ot-scroll-foot, sticky, 1.625rem, the rule on its lower edge) rides the bottom of the box and the text passes under it; on it, at the far end and clear of the rail, waits the first word whose line runs under the foot, in pencil at --ot-pp. Script (useCatchword) finds it by walking the box's text on scroll, resize, change and font load, skipping anything aria-hidden, and sets data-idle when there is none (at the end the word and the rule go). Each new word turns in (data-turn: rises 0.6em and fades up, allegro, exhale). Pointing inks it; pressing it turns the box so that line stands at the top (smooth, at once with reduced motion) without moving focus. It is a pointer aid, hidden from assistive technology: the word is in the box.
  • Wheel, after WOVE, where numbers ride an arc, shrinking and fading by their distance from the chosen one: the children of the box's first child (the rows of one list) turn on an arc as the box scrolls. The row in the middle stands at the start edge, full size, in ink; toward the top and bottom the rows fall back along the curve toward the rail by up to --ot-scroll-arc (--ot-space-7, offset by the square of the distance), shrink to 0.84 and step back through graphite and pencil to the strong rule. Each row runs on its own view timeline (animation-timeline: view()), so the wheel keeps time with the scroll, smooth scrolling included, and nothing moves at rest. The box keeps --ot-scroll-arc clear at its end so no row is cut, and hides sideways overflow. Right-to-left bows the other way. With reduced motion only the ink changes.
  • Every scroller in the library carries the rail: the command list (so the combobox too), the dialog, sheet and drawer, the thread and the sidebar. Nothing on the site scrolls on the browser's own bar. Code and text areas don't scroll at all (they wrap and grow); the native select's picker and the carousel's track hide the bar.

Props

Props of Scroll area
PropTypeDefaultWhat it does
aria-labelstringNames what the box holds. It takes focus so the keyboard can scroll it, and a name makes it a region.
classNamestringGive the box a height or max-height; it scrolls when its content is taller.
sections{ id, num, name }[]Marks on the rail, one per section inside (by its id). Point at the rail and their numbers arrive down it; point at a mark to read its name, press it to scroll there. Sideways content gets the same marks along the bottom.
variant"ruled" | "catchword" | "wheel""ruled"ruled: a rule at an edge while there's more beyond it. catchword, after the printer's: while there's more below, the first word the foot cuts off waits on the foot rule at the far end, and turns in as each new one arrives; press it and the box turns to that line. wheel, after WOVE: the children of what you put inside (the rows of one list) ride an arc as the box scrolls, the one in the middle at the start edge in ink, the rest falling back toward the rail in pencil; with reduced motion only the ink changes.
AccessibilitynoteThe box takes focus, so arrow keys, Page Up and Down, Home and End scroll it. The rail and its marks, and the catchword, are pointer aids hidden from assistive technology (the words they repeat are in the box), so give the sections headings too.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Scroll area"Less is more." rules; Paul Rand dimension linesA rule only where there's more; the box as a ruler, ticked at each section
Catchword scroll areaThe printer's catchwordThe first word below waits alone on the foot line
Wheel scroll areaWOVEThe rows turn on an arc, the middle one in ink

How it moves

How it moves
ComponentArticulationWhat moves
Scroll areaArpeggio, then inkThe page rail's: its sections' numbers arrive down the rail (along it, sideways); held, the thumb takes the accent
Catchword scroll areaTurnEach new catchword rises onto the foot line
Wheel scroll areaWheelThe rows ride the arc as you scroll, inking as they pass the middle