Scroll Area
Augments native scroll functionality for custom, cross-browser styling — a viewport, an auto-hiding scrollbar, and a corner.
- Status
- Since
0.1.0- Accessibility pattern
- focusable scrollable region
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Scroll Area before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify scroll-areaInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/scroll-areaRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
Usage
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
<ScrollArea className="h-[200px] w-[350px] rounded-md border p-4">
Your scrollable content here.
</ScrollArea>;ScrollArea renders the viewport, one vertical ScrollBar and the corner itself, so a vertical
scroll area is a single element. Give it a bounded height — a container that can grow never scrolls.
Anatomy
Examples
Composition
ScrollBar is the one part you compose, and only for the horizontal axis. Everything you pass as a
child lands inside the viewport, so the horizontal bar is written beside the content rather than
outside the component.
ScrollArea
└── ScrollBarHorizontal
Add ScrollBar orientation="horizontal" for horizontal scrolling, and make the content wider than
the area — w-max on an inner flex row, with whitespace-nowrap on the area itself.
RTL
Wrap the subtree in DirectionProvider direction="rtl". The vertical bar sits on the inline start —
border-s on the scrollbar is logical — and the viewport scrolls from the right.
Named region
aria-label or aria-labelledby on ScrollArea names the viewport — the part a keyboard user
tabs to — and makes it a region landmark, so a screen-reader user hears what it holds before
scrolling it. An unnamed scroll area stays a plain scroller.
<h4 id="release-tags">Release tags</h4>
<ScrollArea aria-labelledby="release-tags" className="h-48">
…
</ScrollArea>Release tags
Programmatic scroll
viewportRef is a ref to the scrolling element itself, for jumping to an edge, restoring a saved
position or keeping a log pinned to its end.
const viewportRef = React.useRef<HTMLDivElement>(null);
<ScrollArea
viewportRef={viewportRef}
aria-label="Release tags"
className="h-48"
>
…
</ScrollArea>;
viewportRef.current?.scrollTo({ top: 0 });Playground
Pick the axis the content overflows on and read off the one composition step that follows: a
vertical area is ScrollArea alone, and either horizontal setting adds the ScrollBar.
<ScrollArea className="h-40 w-56 rounded-md border" aria-label="Changesets">
{/* overflowing content */}
</ScrollArea>API Reference
ScrollArea accepts everything the Base UI Scroll Area
Root accepts, plus className, ref and render, and adds one prop of its own: viewportRef
(React.Ref<HTMLDivElement>), a ref to the scrolling viewport. aria-label, aria-labelledby
and tabIndex are forwarded to the viewport rather than the root. ScrollBar adds no props: it
accepts Base UI's Scrollbar props, and its orientation is "vertical" by default and takes
"horizontal" for the other axis.
| Prop | Component | Type | Default | Description |
|---|---|---|---|---|
viewportRef | ScrollArea | React.Ref<HTMLDivElement> | — | A ref to the scrolling viewport. |
orientation | ScrollBar | "vertical" | "horizontal" | "vertical" | The axis the scrollbar tracks. |
Accessibility
- The viewport is a tab stop exactly when it can scroll (A11Y-6). Base UI sets
tabIndex: hidden.x && hidden.y ? -1 : 0, so a keyboard-only reader can reach overflowing content with arrow keys, and an area whose content fits adds no empty stop to the tab order. - Give a scroll area an accessible name —
aria-label, oraria-labelledbypointing at the heading above it. The name lands on the focusable viewport, which becomesrole="region", so a screen-reader user hears what the region holds before entering it (A11Y-6). - Focus is the global 2px
:focus-visibleoutline frombase.css. Upstream's ring glow and itsoutline-noneare removed (FOC-1, FOC-6): the viewport is the one focusable part of this component, so suppressing the outline would leave it with no affordance at all. - The scrollbar and its thumb are pointer affordances; every scroll they perform is reachable from the keyboard through the viewport itself.
- Native scrolling is preserved throughout — wheel, trackpad, touch and the platform's own scroll-into-view all behave as they do on an ordinary overflow container.
| Contract | States tested |
|---|---|
| Behaviour | default, scrollable, not-scrollable |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled |
| Visual | default, scrolling |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/scroll-area.patch. Every hunk:
- FOC-1, FOC-6 — the viewport's focus glow and outline suppression go:
outline-none,focus-visible:ring-[3px],focus-visible:ring-ring/50andfocus-visible:outline-1. The viewport IS a tab stop (see A11Y-6), sooutline-nonewould leave the one focusable part of this component with no affordance. - A11Y-6 — the tab-stop half needs no hunk: Base UI's
ScrollArea.ViewportsetstabIndex: hiddenState.x && hiddenState.y ? -1 : 0, which is exactly "a scroll viewport is a tab stop only when it can actually scroll", andscroll-area.test.tsxasserts both directions. The naming half is a hunk:aria-label,aria-labelledbyandtabIndexgo to the viewport instead of the non-focusable root, and a named viewport isrole="region". - A11Y-22 —
viewportRefis forwarded to the viewport, for programmatic scrolling. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own.