DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
SurfaceContainerStackFlexGridSpacerDividerAspectRatioScroll Area
AI agentsContributing
  1. Components
  2. Scroll Area

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/ui

When 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 by max-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. fade softens 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 import scrollbarSkin (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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  ScrollArea,
  ScrollAreaViewport,
  ScrollAreaScrollbar,
  ScrollAreaThumb,
  ScrollAreaCorner,
  ScrollAreaContent,
} from "@tessinaui/ui";
example.tsx
<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.

example.tsx
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.

example.tsx
<ScrollArea orientation="both">
  {/* content that overflows both axes */}
</ScrollArea>

For custom layouts, pass asChildParts and compose the primitives yourself:

example.tsx
<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

ValueBehaviour
"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

ValueBehaviour
false (default)Only the thumb renders, as an overlay over the content
trueA 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)

PropTypeDefaultDescription
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
railbooleanfalsePaint a track under the thumb
orientation"vertical" | "horizontal" | "both""vertical"Which scrollbars to render
tone"default" | "on-color"inherited from SurfaceDerive the bar from currentColor for a coloured plate
fadebooleanfalseFade content at any overflowing edge
labelstring—Accessible name; promotes the viewport to role="region"
dir"ltr" | "rtl"inheritedReading direction, scroll math included. Unset, the area follows the page; stamped only when set
keepMountedbooleanfalseKeep the bars in the DOM before the first measurement (SSR, tests)
overflowEdgeThresholdnumber | { xStart, xEnd, yStart, yEnd }0Pixels of remaining content before an edge counts as overflowing — also the fade trigger point
asChildPartsbooleanfalseOpt 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

PropTypeDefaultDescription
orientation"vertical" | "horizontal""vertical"Which axis this scrollbar controls
keepMountedbooleanfalseKeep in the DOM even when the axis does not overflow
size, visibility, rail, tone, rounded—inheritedOverride the root's variant

ScrollAreaThumb

PropTypeDefaultDescription
rounded, size, tone—inheritedOverride 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

PropTypeDefaultDescription
size, rounded, orientation, dir—matches the componentGeometry, from the same maps the real bar reads
linesnumber8Placeholder rows
lineCharsnumber42Characters per row, so bars measure like real text
labelstring—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 -1 when neither does, which is the fix for axe's scrollable-region-focusable rule (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. label makes the viewport a role="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-orientation and the four data-overflow-{x,y}-{start,end} attributes are surfaced by Base UI for CSS.

Assistive technology

ATStatus
VoiceOver (macOS/Safari)Passed 2026-09-14. A labelled viewport reads "Terms of service, region" and takes arrow-key scrolling
NVDAUntested — cannot run on the maintainers' machines
JAWSUntested — 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-end on 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 keepMounted to keep them in the DOM. The corner has no such escape and cannot be asserted there.
  • Do not add tabindex yourself — the primitive manages it, and a fixed tabindex="0" would leave a non-scrolling area in the tab order.
AspectRatioButton

On this page

When to useWhen not to useInstallationUsageExamplesDefaultSizesRoundedVisibilityRailEdge fadeA named scroll regionHorizontalBoth axesOn a coloured plateRight to leftCapped by max-h-* alonePinned chrome outside the scrollportSkeletonWhen you can't use it: scrollbarSkinAnatomyThumb colourVisibilityRailOrientationRTLAPI ReferenceScrollArea (root)ScrollAreaViewportScrollAreaScrollbarScrollAreaThumbScrollAreaCornerScrollAreaContentScrollAreaSkeletonExportsAccessibilityAssistive technologyAgent notes