Skip to content
Component installs need the registry setup
VegaStack Design

Scroll Area

Augments native scroll functionality for custom, cross-browser styling — a viewport, an auto-hiding scrollbar, and a corner.

Status
stable
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-area

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/scroll-area

Run 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

ScrollArea — data-slot="scroll-area" | "scroll-area-viewport"
ScrollBar — data-slot="scroll-area-scrollbar" | "scroll-area-thumb"

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
└── ScrollBar

Horizontal

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.

PropComponentTypeDefaultDescription
viewportRefScrollAreaReact.Ref<HTMLDivElement>—A ref to the scrolling viewport.
orientationScrollBar"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, or aria-labelledby pointing at the heading above it. The name lands on the focusable viewport, which becomes role="region", so a screen-reader user hears what the region holds before entering it (A11Y-6).
  • Focus is the global 2px :focus-visible outline from base.css. Upstream's ring glow and its outline-none are 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.
ContractStates tested
Behaviourdefault, scrollable, not-scrollable
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled
Visualdefault, scrolling

Do / Don't

Do
Use a ScrollArea for a bounded region with its own overflow — a list, a log, a code block — and give it an accessible name.
Don't
Wrap the page or a document in one; the browser's own scrollbar is what readers and assistive tech expect at that level.

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/50 and focus-visible:outline-1. The viewport IS a tab stop (see A11Y-6), so outline-none would leave the one focusable part of this component with no affordance.
  • A11Y-6 — the tab-stop half needs no hunk: Base UI's ScrollArea.Viewport sets tabIndex: hiddenState.x && hiddenState.y ? -1 : 0, which is exactly "a scroll viewport is a tab stop only when it can actually scroll", and scroll-area.test.tsx asserts both directions. The naming half is a hunk: aria-label, aria-labelledby and tabIndex go to the viewport instead of the non-focusable root, and a named viewport is role="region".
  • A11Y-22 — viewportRef is forwarded to the viewport, for programmatic scrolling.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header the three-copy model needs; no decision of its own.

On this page