Skip to content

Scroll Area

A scrollable region with quiet overlay scrollbars that match the theme. Native scrolling underneath, consistent chrome on top.

SourceBase UIRegistry
import { Fragment } from "react"

import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"

const releases = Array.from({ length: 40 }, (_, i) => `v1.${40 - i}.0`)

export default function ScrollAreaDemo() {
  return (
    <ScrollArea className="h-64 w-48 rounded-xl border bg-card shadow-raised">
      <div className="p-4">
        <p className="mb-3 text-sm font-semibold text-foreground">Releases</p>
        {releases.map((release, index) => (
          <Fragment key={release}>
            {index > 0 && <Separator className="my-2" />}
            <div className="font-mono text-code text-muted-foreground">
              {release}
            </div>
          </Fragment>
        ))}
      </div>
    </ScrollArea>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/scroll-area.json

Usage

import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
<ScrollArea className="h-72 w-48 rounded-xl border">
  {/* long content */}
</ScrollArea>

Give the scroll area a fixed height (or width, for horizontal content) — it scrolls whatever overflows that box. Scrolling itself is native: wheel, trackpad momentum, touch, keyboard and scroll anchoring all behave exactly as they would on a plain overflow: auto element.

Examples

Horizontal

Content that should scroll sideways needs to be wider than the area (w-max on a flex row) and a horizontal scrollbar. Add <ScrollBar orientation="horizontal" /> as a child — the vertical one is always included and only appears when there's vertical overflow.

import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"

const works = [
  { title: "Meridian", artist: "Ola Nowak" },
  { title: "Low Tide", artist: "Jonas Berg" },
  { title: "Ember", artist: "Maya Chen" },
  { title: "Field Notes", artist: "Iris Vale" },
  { title: "Nocturne", artist: "Theo Park" },
  { title: "Bloom", artist: "Lena Roth" },
]

export default function ScrollAreaHorizontal() {
  return (
    <ScrollArea className="w-full max-w-lg rounded-xl border bg-card">
      <div className="flex w-max gap-4 p-4">
        {works.map((work) => (
          <figure
            key={work.title}
            className="flex w-40 shrink-0 flex-col gap-2"
          >
            <div className="aspect-[3/4] rounded-lg bg-muted" />
            <figcaption className="flex flex-col">
              <span className="text-sm font-medium text-foreground">
                {work.title}
              </span>
              <span className="text-xs text-muted-foreground">
                {work.artist}
              </span>
            </figcaption>
          </figure>
        ))}
      </div>
      <ScrollBar orientation="horizontal" />
    </ScrollArea>
  )
}

Both axes

Wide data — spreadsheets, timelines, large tables — can scroll in both directions. Sticky headers and first columns keep their bearings; the corner where scrollbars meet stays clear.

import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"

const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
]
const regions = [
  "North America",
  "South America",
  "Western Europe",
  "Central Europe",
  "Nordics",
  "Middle East",
  "Africa",
  "South Asia",
  "East Asia",
  "Oceania",
]

function value(row: number, col: number) {
  return (((row + 3) * (col + 7) * 37) % 900) + 100
}

export default function ScrollAreaBothAxes() {
  return (
    <ScrollArea className="h-72 w-full max-w-lg rounded-xl border bg-card shadow-raised">
      <table className="w-max border-collapse text-sm numeric">
        <thead className="sticky top-0 z-raised bg-surface">
          <tr className="border-b">
            <th
              scope="col"
              className="sticky left-0 z-raised bg-surface px-4 py-2.5 text-left text-xs font-medium text-muted-foreground"
            >
              Region
            </th>
            {months.map((month) => (
              <th
                key={month}
                scope="col"
                className="px-4 py-2.5 text-right text-xs font-medium text-muted-foreground"
              >
                {month}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {regions.map((region, row) => (
            <tr key={region} className="border-b last:border-0">
              <td className="sticky left-0 bg-card px-4 py-2.5 whitespace-nowrap text-foreground">
                {region}
              </td>
              {months.map((month, col) => (
                <td
                  key={month}
                  className="px-4 py-2.5 text-right text-muted-foreground"
                >
                  {value(row, col).toLocaleString("en-US")}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <ScrollBar orientation="horizontal" />
    </ScrollArea>
  )
}

Fading edges

The root reports where content overflows through data-overflow-y-start and data-overflow-y-end (and -x- equivalents). Fade the edge only when there is more content in that direction — a cue that the region scrolls, which disappears once you reach the end.

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"

const activity = [
  {
    who: "AK",
    name: "Ana Kowalska",
    what: "merged #482 into main",
    when: "2m",
  },
  {
    who: "JB",
    name: "Jonas Berg",
    what: "commented on Tokens v2",
    when: "14m",
  },
  {
    who: "MC",
    name: "Maya Chen",
    what: "opened Dialog focus trap bug",
    when: "1h",
  },
  { who: "TP", name: "Theo Park", what: "released v1.4.0", when: "3h" },
  {
    who: "IV",
    name: "Iris Vale",
    what: "invited 3 people to Design",
    when: "5h",
  },
  { who: "LR", name: "Lena Roth", what: "archived Legacy icons", when: "1d" },
  {
    who: "OS",
    name: "Omar Said",
    what: "updated the contrast tests",
    when: "1d",
  },
  { who: "AK", name: "Ana Kowalska", what: "approved #479", when: "2d" },
]

const fade =
  "before:pointer-events-none before:absolute before:inset-x-0 before:right-2.5 before:top-0 before:z-raised before:h-10 before:rounded-t-xl before:bg-linear-to-b before:from-card before:to-transparent before:opacity-0 before:transition-opacity before:duration-base data-overflow-y-start:before:opacity-100 " +
  "after:pointer-events-none after:absolute after:inset-x-0 after:right-2.5 after:bottom-0 after:z-raised after:h-10 after:rounded-b-xl after:bg-linear-to-t after:from-card after:to-transparent after:opacity-0 after:transition-opacity after:duration-base data-overflow-y-end:after:opacity-100"

export default function ScrollAreaFadeEdges() {
  return (
    <ScrollArea
      className={cn(
        "h-72 w-full max-w-sm rounded-xl border bg-card shadow-raised",
        fade
      )}
    >
      <ul className="flex flex-col p-2">
        {activity.map((item, index) => (
          <li
            key={index}
            className="flex items-center gap-3 rounded-lg px-2 py-2.5"
          >
            <Avatar size="sm">
              <AvatarFallback>{item.who}</AvatarFallback>
            </Avatar>
            <p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
              <span className="font-medium text-foreground">{item.name}</span>{" "}
              {item.what}
            </p>
            <span className="text-xs text-muted-foreground numeric">
              {item.when}
            </span>
          </li>
        ))}
      </ul>
    </ScrollArea>
  )
}
<ScrollArea
  className={cn(
    "relative h-72",
    "after:absolute after:inset-x-0 after:bottom-0 after:h-10 after:bg-linear-to-t after:from-card",
    "after:opacity-0 after:transition-opacity data-overflow-y-end:after:opacity-100"
  )}
>

Scrollbars

Scrollbars overlay the content instead of taking space from it, so layouts don't shift between platforms. They stay hidden until you hover the area or scroll, then fade away; the thumb darkens on hover and can be dragged.

Guidelines

When to use

  • For bounded regions inside a layout that scroll independently: menus, side panels, chat threads, lists in cards, code.
  • When the platform's default scrollbar would clash with a dense, themed interface — especially thick, always-visible scrollbars on Windows.

When not to use

  • For the page itself. Let the document scroll natively; nested scrolling for the main content makes people fight two scroll positions.
  • To hide content that doesn't fit. If people must scroll a small box to find something important, give it more room.
  • For horizontal scrolling of primary content on desktop. People miss sideways overflow; prefer wrapping or pagination.

Show that it scrolls

Overlay scrollbars are hidden at rest, so the region itself has to suggest that there's more. Cut content mid-item at the edge, fade the overflowing edge, or show a count.

Changelog

v1.4.0 — Tokens v2

v1.3.2 — Fixes

v1.3.1 — Dialog focus

DoThe last item is cut at the edge — it obviously continues.

Changelog

v1.4.0 — Tokens v2

v1.3.2 — Fixes

Don’tThe box ends exactly between items — nothing says there's more.

One direction at a time

Prefer a single scroll axis per region. Two-axis scrolling is right for grids of data, where people expect it; elsewhere it makes content feel lost.

Accessibility

  • Keyboard scrolling. The viewport becomes a tab stop only while its content overflows. Once focused, ↑ ↓ ← →, Page Up, Page Down, Home, End and Space scroll it natively, and a 2px inset focus ring shows where you are.
  • Name the region. A focusable scroll region with no name is announced as nothing in particular. When the content is meaningful on its own, wrap it in a labelled landmark (<section aria-label="Release history">) or give it a visible heading.
  • Scrollbars are pointer affordances. Screen reader and keyboard users scroll the viewport directly; the custom scrollbars are hidden from assistive technology like native ones.
  • Reduced motion. Scrollbar fades are instant with prefers-reduced-motion.

API reference

ScrollArea

The scroll container: root, viewport, content wrapper, a vertical ScrollBar and the corner. className applies to the root. Children render inside the content wrapper (data-slot="scroll-area-content"), which Base UI measures so the thumb follows content that grows or shrinks; ScrollBar children render beside the viewport instead. Accepts every prop of Base UI ScrollArea.Root.

PropTypeDefault
overflowEdgeThreshold

Pixels of overflow required before the data-overflow-* edge attributes are set.

number | { xStart?, xEnd?, yStart?, yEnd? }0
className

Styles the root. Set a fixed height or width here.

stringNo default

ScrollBar

A scrollbar and its thumb. Add one with orientation="horizontal" for sideways scrolling. Accepts every prop of Base UI ScrollArea.Scrollbar.

PropTypeDefault
orientation

The axis the scrollbar controls.

"vertical" | "horizontal""vertical"
keepMounted

Keeps the scrollbar in the DOM even when that axis doesn't overflow.

booleanfalse

Data attributes

Set on the root; use them to style edges and states.

AttributePresent when
data-has-overflow-x / data-has-overflow-yContent overflows on that axis.
data-overflow-x-start / data-overflow-x-endThere is more content to the left / right.
data-overflow-y-start / data-overflow-y-endThere is more content above / below.
data-scrollingThe area is being scrolled.