Surfaces
ScrollArea
A box that scrolls, with a scrollbar that looks the same everywhere.
Usage
When to reach for it
import { ScrollArea } from '@misoto22/design'Notes
A box that scrolls, with a scrollbar that looks the same on every platform.
The reason to reach for this over overflow-auto is not the scrollbar — it is that Radix keeps the viewport focusable and the bar operable, which a bare overflow container does not. A scrollable region whose contents are not themselves focusable is unreachable by keyboard: there is nothing to Tab to, so everything past the fold does not exist without a mouse.
For a page-level or prose scroll, the scroll-slim utility is lighter and needs no component. This is for a bounded panel: a long option list, a log, a sidebar that outgrows its column.
Both axes scroll unless a caller says otherwise. The old vertical default read as a statement about which bar to draw and was in fact a statement about which half of the content existed: Radix sets the viewport's overflow from the mounted bars, so the axis without one was hidden and everything past it was unreachable — silently, and with the content perfectly well rendered.
Anatomy
| Element | Description |
|---|---|
| Rootrequired | The bounded box, and where className lands. It is overflow-hidden and has no size of its own, so the height you give it here is the only thing that decides whether anything scrolls at all. |
| Viewportrequired | The element that actually scrolls, and the one carrying role="region", tabIndex 0 and label. Radix hides the platform’s scrollbar on it and sets the axis WITHOUT a bar to overflow: hidden. It is positioned, so an absolutely-positioned descendant travels with the content instead of hanging still over it. |
| Bar | One per orientation: an 8px track with a pill thumb at --rule-2, and touch-none — a finger scrolls the content, not the bar. It is drawn only while the pointer is inside the region and fades some 600ms after scrolling stops. |
| Corner | The square where two bars meet, which exists only at orientation="both" — the default. |
Best practices
Do
- Give it a height. With none, the root is as tall as its content, nothing ever overflows, and all the component added to the page was a keyboard stop.
- Narrow orientation only when clipping the other axis is the thing you meant. Both axes scroll by default, because the axis without a bar is set to overflow: hidden — what is past that edge is not merely unmarked, it is unreachable by every key and every gesture, with the content perfectly well rendered.
- Pass type="always" when the content ends flush at the boundary: the platform scrollbar is hidden and ours is not drawn until the pointer is inside, so at rest nothing on the screen says the box scrolls.
Don’t
- Do not build a drag-to-scroll affordance over it — the thumb is deliberately touch-none and the viewport is a real overflow container, so touch dragging, momentum and the wheel are already the platform’s and behave as the reader expects.
- Do not nest one inside another on the same axis: the inner viewport consumes the wheel until it reaches its own end, so a reader aiming at the outer list moves the inner one instead.
Examples
a bounded log
The height on the root is what makes any of this happen: with none, the box is as tall as its content, nothing overflows, and all the component added to the page was a keyboard stop. That stop is the reason to reach for this over a bare overflow-auto — Radix keeps the viewport focusable and the bar operable, so everything past the fold is reachable without a mouse. label is required for the same reason: an unnamed keyboard stop announces group and nothing else. For a page-level or prose scroll the scroll-slim utility is lighter and needs no component at all.
both axes
orientation defaults to both, and it is written out here because the axis WITHOUT a bar is set to overflow: hidden — so narrowing this to vertical would leave content wider than the box not merely unmarked but clipped, with no key and no gesture reaching it. Narrow it only when one axis genuinely cannot overflow. The corner square only exists at both. Do not nest one of these inside another on the same axis, either: the inner viewport consumes the wheel until it reaches its own end, so a reader aiming at the outer list moves the inner one instead.
a permanent bar
type="always" draws both bars whether or not the pointer is inside. The default is hover, and the platform's own bar is hidden, so at rest there is nothing on screen saying the box scrolls — which is fine when the last row is visibly cut in half, and a trap when the content happens to end flush at the boundary, as this list nearly does. Do not build a drag-to-scroll affordance over it: the thumb is deliberately touch-none and the viewport is a real overflow container, so touch dragging, momentum and the wheel are already the platform's and behave the way the reader expects.
Keyboard
| Key | Does |
|---|---|
| Tab | Moves focus into the region, which is what makes it scrollable at all without a mouse. |
| ↑↓Page UpPage Down | Scrolls it. |
Accessibility
- The viewport stays focusable. A scrollable region whose contents are not focusable has nothing to Tab to, so everything past the fold does not exist without a mouse.
- label is required, because an unnamed keyboard stop announces "group" and nothing else.
- Both axes scroll unless a caller narrows orientation, so content wider than the box stays reachable rather than being clipped without a bar.