Scroll Area
A scrollable container with custom overlay scrollbars that do not steal layout space. Vertical, horizontal or both axes, an optional edge fade, a named region for keyboard users, real RTL, and a compound API for full control.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
ScrollArea,
ScrollAreaViewport,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaCorner,
ScrollAreaContent,
} from "@tessinaui/ui";<ScrollArea className="h-72 w-64 rounded-md border border-border bg-card">
<div className="p-4">{/* tall content */}</div>
</ScrollArea>When to use it
A ScrollArea replaces the browser's scrollbar with a branded overlay bar. Reach for it when a fixed-height region holds more content than fits and you want the bar to look the same on every platform without pushing the content around.
| Use ScrollArea | Use something else |
|---|---|
| A fixed-height panel with tall content | Page-level scrolling — leave that to the browser |
| A list, log, transcript or option set inside a card | A horizontal rail with next/previous buttons — that is Carousel variant="scroll" |
| You want the bar to overlay rather than consume width | A scrollport that carries its own ARIA role — see scrollbarSkin below |
| The bar must look identical across Windows, macOS and Linux | You are happy with the native bar. Nielsen Norman's advice is to prefer it |
When not to
Custom scrollbars are a real accessibility risk, and the industry position is
worth stating plainly rather than burying: Nielsen Norman Group recommends the
native scrollbar, and MDN warns that hiding one without another affordance
breaks keyboard and assistive access. This component exists because a design
system needs one consistent bar, so it accepts that trade deliberately and pays
for it — the thumb is contrast-gated, the viewport stays keyboard-focusable and
nameable, and the native scrollport keeps a documented escape hatch. If you do
not need a consistent look, native overflow-auto is the better default.
Examples
Default
A scrollable panel with the default vertical overlay scrollbar.
Sizes
Scrollbar thickness — sm, md, and lg. The thumb scales with the track, and
the bar is held clear of the frame's ends so its rounded caps never run into a
rounded frame's corner arc.
Rounded
The thumb and its rail follow the rounded cascade together, so a square area
never shows a pill thumb.
The ladder is proportional to the bar's own thickness, not to the page's
--radius-* tokens, and that is not a stylistic choice. CSS clamps a painted
radius at half the box's short side, and the thumb is only 4–8px wide — so the
page-scale tokens this replaced painted identically from sm upward
(measured: sm, md, lg and full all rendered 4px; only none differed —
five API rungs, two painted states). Each size now has its own ladder, so the
rungs really differ. Below about 6px of bar the top rungs still converge; that
is the clamp, not a bug, and size="lg" is where all five are distinct.
To tune a corner without re-deriving the ladder, set
--scroll-area-thumb-radius / --scroll-area-rail-radius on the root.
Visibility
Controls when the scrollbar appears — always, hover, or scroll.
Rail
Add rail to paint a subtle track background beneath the thumb for a native-OS look.
Edge fade
fade softens the content at any edge that has more content beyond it — the
"there is more this way" cue that a modal's inset panel, a filter list or a
transcript benefits from.
It reads Base UI's own data-overflow-* attributes, so there is no scroll
listener and no JavaScript behind it, and it is a mask rather than a colour
gradient — it fades the content itself, so it stays correct on a light plate, a
dark plate, a tint or a photograph without being told the background colour.
The fade applies to one axis: orientation="horizontal" fades left/right,
and both "vertical" and "both" fade top/bottom. mask-image is a single CSS
property, so fading two axes at once would need a composited two-layer mask —
deliberately not built, because no surveyed design system ships a two-axis fade
and leaving two rules to race would make which axis faded depend on stylesheet
order.
A named scroll region
An overflowing viewport is focusable (see Accessibility), so
keyboard users land on it. Give it a label and it announces itself as a real
region instead of an anonymous stop.
Horizontal
Set orientation="horizontal" for a row of cards that overflows sideways.
Both axes
With orientation="both", content scrolls in both dimensions and a corner fills the intersection.
On a coloured plate
Inside a Surface, the thumb and rail derive from currentColor, so they read
against the plate instead of falling back to a theme grey. tone is inherited —
you rarely set it by hand.
Right to left
dir="rtl" mirrors the bar and flips the scroll math: the thumb tracks
content that begins at the right edge.
Pinned chrome outside the scrollport
An action row must never scroll out of reach, so it is a sibling of the scroll area, not a child of it. This is how filter panels and consent dialogs are built in the wild.
Skeleton
ScrollAreaSkeleton mirrors the viewport and bar geometry while content loads.
It is silent unless you give it a label — a list of them would otherwise be
one announcement per placeholder.
When you can't use it: scrollbarSkin
Some scrollports can't hand the scrolling to a wrapper, because the scrolling
element is the element that carries the ARIA role — role="listbox",
"menu", "grid". Moving the scroll one level in or out changes what the role
owns, so those keep the native scrollport and import the house skin instead.
import { scrollbarSkin } from "@tessinaui/ui";
<div role="listbox" className={cn("overflow-y-auto max-h-72", scrollbarSkin)}>
{/* … */}
</div>scrollbarSkin applies the house ink through scrollbar-width /
scrollbar-color, with a WebKit fallback for Safari < 18. It takes layout space
like any native scrollbar (it does not overlay), and it can never go stale.
That last point is why Combobox uses it. On Base UI 1.3.0 the only observer that
watches the content box lives in ScrollArea.Content; the Viewport's own
ResizeObserver watches the Viewport's box, which a filter does not resize. So a
list whose content height changes without resizing that box — a filtered option
list — is never re-measured, and a correctly-sized-for-20 thumb stays painted
over a list of 5. Reach for scrollbarSkin whenever a role-bearing scrollport
has content that changes underneath it.
Anatomy
Most use-cases only need <ScrollArea>. When orientation is set, the appropriate scrollbar(s) and corner are auto-rendered.
<ScrollArea orientation="both">
{/* content that overflows both axes */}
</ScrollArea>For custom layouts, pass asChildParts and compose the primitives yourself:
<ScrollArea asChildParts>
<ScrollAreaViewport>{/* content */}</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollArea>Every part stamps a data-slot, so tests and agents have a stable target:
scroll-area, scroll-area-viewport, scroll-area-content,
scroll-area-scrollbar, scroll-area-thumb, scroll-area-corner,
scroll-area-skeleton.
Thumb colour
The thumb is a single neutral in both themes, derived from the foreground
token: 50% at rest, 70% on hover, 90% while dragged.
Those numbers are a floor, not a preference. The thumb is the component's only affordance, so WCAG 1.4.11 asks for 3:1 against the surface behind it — and a native scrollbar is exempt from that rule only because the user agent decides how it looks. The moment we paint our own, we own the contrast. Measured on the live component: 3.74:1 light · 4.74:1 dark at rest.
We deliberately don't ship semantic (error/warning/success/info) colouring for the thumb — a scrollbar is chrome and carries no status. Use the surrounding surface, alert or status components for that.
Visibility
| Value | Behaviour |
|---|---|
"always" (default) | Visible whenever the viewport overflows |
"hover" | Visible on hover or while scrolling — the second half matters, because the hover signal is pointer-only and a touch device would otherwise never see the bar |
"scroll" | Visible only while scrolling |
The house default is always, and that is a deliberate divergence: Radix
defaults to hover and Apple's guidance is scroll-revealed. The reason is where
this component is actually used — dense panels (command palettes, sidebars,
option lists, logs) where a persistent bar tells you how much more there is.
On a phone nothing hovers, so hover still reveals on scroll.
Rail
| Value | Behaviour |
|---|---|
false (default) | Only the thumb renders, as an overlay over the content |
true | A track paints under the thumb, closer to a native OS scrollbar. Useful when the thumb alone feels like it is floating, or when seeing the full scrollable extent helps |
Orientation
orientation controls which scrollbars render. The default is vertical-only.
When it is "both", a transparent corner fills the intersection so the two
rails do not overlap.
RTL
Pass dir="rtl" to the root.
This does two things, and the second is easy to get wrong: it mirrors the bar's
position through logical CSS, and it wraps Base UI's DirectionProvider.
The primitive resolves direction from that context only — it never reads the DOM
dir attribute — so setting the attribute alone mirrors the bar visually while
leaving the scroll math, the thumb offset and the overflow-edge start/end
mapping on the left-to-right path. Both halves ship together here.
Accessibility
- The viewport is focusable when it overflows. Base UI sets
tabindex="0"while either axis has overflow and-1when neither does, which is the fix for axe'sscrollable-region-focusablerule (WCAG 2.1.1 Keyboard). - Keyboard scrolling is the browser's, not ours: once the viewport has focus, Arrow keys, Page Up / Page Down, Home / End and Space all work natively. The component binds no keys of its own.
- Give it a name. A focusable element with no accessible name is a tab stop
that announces nothing.
labelmakes the viewport arole="region"with that name — the recipe the MOJ "Scrollable pane" pattern specifies. It is opt-in so that a page full of small scroll areas does not mint a landmark for each one. - The scrollbar itself is decorative. It carries no role and is not a tab stop; it is a pointer affordance over scrolling the browser already provides.
data-hovering,data-scrolling,data-orientationand the fourdata-overflow-{x,y}-{start,end}attributes are surfaced by Base UI for CSS.
Assistive technology
| AT | Status |
|---|---|
| VoiceOver (macOS/Safari) | Not verified this round — the badge is beta until it is |
| NVDA | Untested — cannot run on the maintainers' machines |
| JAWS | Untested — same |
API Reference
ScrollArea (root)
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Scrollbar thickness |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Thumb and rail corner radius |
visibility | "always" | "hover" | "scroll" | "always" | When the scrollbar is visible |
rail | boolean | false | Paint a track under the thumb |
orientation | "vertical" | "horizontal" | "both" | "vertical" | Which scrollbars to render |
tone | "default" | "on-color" | inherited from Surface | Derive the bar from currentColor for a coloured plate |
fade | boolean | false | Fade content at any overflowing edge |
label | string | — | Accessible name; promotes the viewport to role="region" |
dir | "ltr" | "rtl" | inherited | Reading direction. Only stamped when set; also wraps DirectionProvider |
keepMounted | boolean | false | Keep the bars in the DOM before the first measurement (SSR, tests) |
overflowEdgeThreshold | number | { xStart, xEnd, yStart, yEnd } | 0 | Pixels of remaining content before an edge counts as overflowing — also the fade trigger point |
asChildParts | boolean | false | Opt out of auto-rendering; compose parts manually |
All other standard <div> props (className, style, id, data-*, aria-*)
are forwarded to the root element.
ScrollAreaViewport
The scrollable container. Takes fade and label (the root passes its own
through). Use className to add padding or constrain the inner content.
ScrollAreaScrollbar
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | Which axis this scrollbar controls |
keepMounted | boolean | false | Keep in the DOM even when the axis does not overflow |
size, visibility, rail, tone, rounded | — | inherited | Override the root's variant |
ScrollAreaThumb
| Prop | Type | Default | Description |
|---|---|---|---|
rounded, tone | — | inherited | Override the root's variant |
ScrollAreaCorner
Renders at the intersection of the two scrollbars. Transparent by default. Base UI mounts it only once both axes actually overflow.
ScrollAreaContent
Optional inner wrapper inside the viewport. It is what carries the content-box ResizeObserver, so use it when the content's height changes without the viewport resizing.
ScrollAreaSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size, rounded, orientation, dir | — | matches the component | Geometry, from the same maps the real bar reads |
lines | number | 8 | Placeholder rows |
lineChars | number | 42 | Characters per row, so bars measure like real text |
label | string | — | Announce loading. Silent when unset |
Exports
scrollbarSkin (native scrollport skin), scrollAreaBarThickness (the shared
size→thickness map), scrollAreaFadeClasses, scrollAreaScrollbarVariants,
scrollAreaThumbVariants.
Agent notes
- The root is
data-slot="scroll-area"; every part has its own slot (listed under Anatomy). Target those, not class names. - To assert "is there more content below", read
data-overflow-y-endon the viewport rather than computing scroll positions. - In a test environment with no layout (jsdom), Base UI unmounts the scrollbars
because it can never measure overflow. Pass
keepMountedto keep them in the DOM. The corner has no such escape and cannot be asserted there. - Do not add
tabindexyourself — the primitive manages it, and a fixedtabindex="0"would leave a non-scrolling area in the tab order.