DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 ScrollAreaUse something else
A fixed-height panel with tall contentPage-level scrolling — leave that to the browser
A list, log, transcript or option set inside a cardA horizontal rail with next/previous buttons — that is Carousel variant="scroll"
You want the bar to overlay rather than consume widthA scrollport that carries its own ARIA role — see scrollbarSkin below
The bar must look identical across Windows, macOS and LinuxYou 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

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: 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

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

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 -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 — 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-orientation and the four data-overflow-{x,y}-{start,end} attributes are surfaced by Base UI for CSS.

Assistive technology

ATStatus
VoiceOver (macOS/Safari)Not verified this round — the badge is beta until it is
NVDAUntested — cannot run on the maintainers' machines
JAWSUntested — same

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. Only stamped when set; also wraps DirectionProvider
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 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, tone—inheritedOverride 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

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.

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.
RatingSearch

On this page

PlaygroundInstallationUsageWhen to use itWhen not toExamplesDefaultSizesRoundedVisibilityRailEdge fadeA named scroll regionHorizontalBoth axesOn a coloured plateRight to leftPinned chrome outside the scrollportSkeletonWhen you can't use it: scrollbarSkinAnatomyThumb colourVisibilityRailOrientationRTLAccessibilityAssistive technologyAPI ReferenceScrollArea (root)ScrollAreaViewportScrollAreaScrollbarScrollAreaThumbScrollAreaCornerScrollAreaContentScrollAreaSkeletonExportsAgent notes