---
title: "ScrollArea"
description: "A bounded scrolling region with a thin neutral scrollbar that keeps a card or panel's overflow reachable without replacing the page's own native scroll."
url: "https://opsinjs.pensievelabs.org/components/scroll-area"
source: "https://opsinjs.pensievelabs.org/components/scroll-area.md"
section: "Components"
status: "shipped"
kind: "component"
category: "layout"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["scroller", "overflow container"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="scroll-area"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured for the thumb against the surfaces it rides over, at the 3:1 non-text floor for a control.&#x22;,
  &#x22;Nobody has scrolled this region with a screen reader or a switch device, so whether a reader is told they entered a scrollable region is unknown.&#x22;,
  &#x22;Behaviour at 200% text and under browser zoom is untested, and a height ceiling that clips is the exact failure this component exists to avoid.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="scroll-area" />

## Installation [#installation]

<ComponentInstall name="scroll-area" unbuilt="false" importPath="@/components/ui/scroll-area" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { ScrollArea } from "@/components/ui/scroll-area"
```

```tsx
<ScrollArea maxHeight="16rem" className="rounded-lg border">
  <ul>
    {items.map((item) => (
      <li key={item.id}>{item.label}</li>
    ))}
  </ul>
</ScrollArea>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Bounding one card, panel or list to a height where its content is longer than the box.&#x22;,
  &#x22;A wide row of cards or a table that scrolls sideways inside its column.&#x22;,
  &#x22;Holding one block's height steady on a surface that still scrolls natively.&#x22;,
]"
  avoid="[
  { case: &#x22;Wrapping the whole page or the reading column, where a bounded height clips content at 200% text.&#x22;, instead: &#x22;the browser's own page scroll&#x22;, href: &#x22;../accessibility/text-resizing-and-zoom.mdx&#x22; },
  { case: &#x22;A small block that already fits, wrapped on the chance that it might grow.&#x22;, instead: &#x22;card&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="scroll-area"
  parts="[
  {
    name: &#x22;ScrollArea&#x22;,
    describes: &#x22;The root. Takes the region's width, border and rounding, and draws neutral chrome on neither colour axis.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;ScrollArea.Viewport&#x22;,
    describes: &#x22;The scrollable element. Base UI gives it overflow scroll and a tab stop. maxHeight and the focus ring land here.&#x22;,
    prop: &#x22;maxHeight&#x22;,
  },
  {
    name: &#x22;ScrollArea.Scrollbar&#x22;,
    describes: &#x22;The rail for one axis. Its track is transparent, and it fades in on hover and while scrolling.&#x22;,
    prop: &#x22;orientation&#x22;,
  },
  {
    name: &#x22;ScrollArea.Thumb&#x22;,
    describes: &#x22;The grabbable handle. Base UI sizes its length, and this component paints it the muted-foreground role.&#x22;,
  },
  {
    name: &#x22;ScrollArea.Corner&#x22;,
    describes: &#x22;The square where two rails meet, drawn only when orientation is both. It draws no fill.&#x22;,
  },
]"
/>

<CompositionTree
  name="scroll-area"
  tree="[
  {
    part: &#x22;ScrollArea&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;The root; neither data-status nor data-category&#x22;,
    children: [
      {
        part: &#x22;ScrollArea.Viewport&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;One tab stop, carries the focus ring&#x22;,
      },
      {
        part: &#x22;ScrollArea.Scrollbar&#x22;,
        cardinality: &#x22;1..2&#x22;,
        note: &#x22;One per scrolling axis; transparent track&#x22;,
        children: [
          {
            part: &#x22;ScrollArea.Thumb&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;The muted-foreground handle&#x22;,
          },
        ],
      },
      {
        part: &#x22;ScrollArea.Corner&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;Drawn only when orientation is both&#x22;,
      },
    ],
  },
]"
/>

## Examples [#examples]

### A long list [#a-long-list]

The list outruns its box, so `maxHeight` bounds the region and the rail appears
while the page around it keeps its native scroll.

<ComponentPreview name="scroll-area-a-long-list" kind="example" align="start" />

### In a card [#in-a-card]

One bounded panel with a heading fixed above the region, so `maxHeight` makes
only the card body overflow and scroll.

<ComponentPreview name="scroll-area-in-a-card" kind="example" align="start" />

## Accessibility [#accessibility]

This component was audited against WCAG 2.2 AA in a source pass and a rendered
pass. The audit is author-run, not an independent review, and clinical review is
still pending.

The audit found one serious defect and fixed it. The viewport was not a reliable
keyboard tab stop and carried no accessible name, so a screen reader that reached
it met an unnamed stop. It now holds a tab stop and takes a name through `label`
or `labelledBy`, and the default demo and both examples name their region, one by
pointing `labelledBy` at its on-screen heading.

The rendered pass found nothing else to change: no colour-axis, token, `px`, or
colour-literal issue, and reflow and target size passed. Two items stay open. In
the card example the rounded corners can partly clip the focus ring at the flush
bottom edge, though the indicator stays perceivable. The thumb's contrast and the
pairs below are not measured in source, so the report below stands as its own
record.

<KeyboardTable
  name="scroll-area"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus onto the viewport when it is scrollable&#x22;,
    notes: &#x22;One tab stop for the region. Base UI drops it when there is nothing to scroll.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out of the viewport&#x22;,
    notes: &#x22;A single stop in reverse too, so focus does not step through the rails.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Scroll the focused viewport&#x22;,
    notes: &#x22;The browser's own behaviour on a focused scroll container, along the allowed axes.&#x22;,
  },
  {
    keys: &#x22;Page Up, Page Down&#x22;,
    action: &#x22;Scroll the focused viewport by a page&#x22;,
    notes: &#x22;The browser's default, not a key this component binds.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Scroll to the start or end of the focused viewport&#x22;,
    notes: &#x22;Again the browser's default on a scroll container.&#x22;,
  },
]"
/>

<ContrastReport component="scroll-area" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="scroll-area"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every inner part, always&#x22;,
    value: &#x22;scroll-area, scroll-area-viewport, scroll-area-scrollbar, scroll-area-thumb, scroll-area-corner&#x22;,
  },
  {
    attribute: &#x22;data-orientation&#x22;,
    condition: &#x22;On a scrollbar, set by Base UI&#x22;,
    value: &#x22;vertical or horizontal&#x22;,
  },
  {
    attribute: &#x22;data-hovering, data-scrolling&#x22;,
    condition: &#x22;On a scrollbar while hovered or while the region scrolls&#x22;,
    value: &#x22;What the rail's fade-in is keyed to&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="ScrollAreaProps" />

`maxHeight` gives the region something to overflow, so set it in a rem rather
than a pixel count. Given neither it nor a height class, the region grows to fit
and a development warning names the omission. `className` is the one route by
which colour reaches the region, and the two-colour-axes rule applies in full.

## Related [#related]

* [Card](./card.mdx) draws a bounded surface but grows with its content rather
  than scrolling it.
* [Sheet](./sheet.mdx) is an overlay owning its own scroll, not an inline region.
* [Table](./table.mdx) is content a scroll area may wrap to scroll sideways.
