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.
pnpm add @tessinaui/uiWhen to use
- A fixed-height region with more content than fits: a panel, list, log, transcript or option set inside a card.
- A bar that looks the same on every platform and overlays the content instead of consuming width.
- A capped panel. A sheet, drill-in panel or dropdown with only a
max-h-*grows with its content, then scrolls (Capped bymax-h-*alone). - A strip that overflows sideways, such as a board of cards:
orientation="horizontal", or"both"when it overflows two ways. - A cue that there is more.
fadesoftens any edge that still has content beyond it.
When not to use
- For page-level scrolling. Leave the page to the browser.
- For a rail that pages with next and previous buttons. Use
Carousel with
variant="scroll". - For a scrollport that carries its own ARIA role (
listbox,menu,grid). Keep the native scrollport and importscrollbarSkin(When you can't use it). - For something the user must act on. Keep an action row pinned outside the scrollport, as a sibling (Pinned chrome), so it never scrolls out of reach.
- Inside another scroll area on the same axis. Two nested bars moving the same way are hard to control.
- When you do not need a consistent look. Prefer the native scrollbar and plain
overflow-auto: it needs no component.
Custom scrollbars are a real accessibility risk, so the position is stated plainly: hiding the native bar without another affordance breaks keyboard and assistive access. This component 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.
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>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.
Capped by max-h-* alone
A root that is only capped — max-h-* with no h-*, and not a stretched
flex or grid item — is still a scrollport. The frame grows with its content up
to the cap, then the viewport takes over and scrolls. This is the shape a
sheet, a drill-in panel or a dropdown wants.
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. This one is labelled, so it is announced once
as it appears.
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. In Base UI 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: the common
defaults are hover and 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
An RTL page needs nothing: the area reads the direction around it. Pass
dir="rtl" to set one the page does not have.
Either way, two things change together: the bar's position mirrors through logical CSS, and the scroll math follows — the thumb offset and the overflow-edge start/end mapping take the right-to-left path, not only the paint.
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, scroll math included. Unset, the area follows the page; stamped only when set |
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, fadeAxis 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, size, tone | — | inherited | Override the root's variant. size picks which radius ladder rounded reads |
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.
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. It is opt-in so that a page full of small scroll areas does not mint a landmark for each one. - The focus ring sits inside the frame. The root clips its own overflow and the viewport fills it, so a ring drawn outside the viewport would be cut off entirely. It is drawn inset instead, in the plate's ink on a coloured surface.
- 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) | Passed 2026-09-14. A labelled viewport reads "Terms of service, region" and takes arrow-key scrolling |
| NVDA | Untested — cannot run on the maintainers' machines |
| JAWS | Untested — same |
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.