Scroll area
A region that scrolls inside a fixed height, with a thin overlay scrollbar.
shadcn's base-nova scroll area — Base UI's viewport with a thin thumb drawn in --border — with two changes. The scroller takes the name: aria-label or aria-labelledby given to the scroll area lands on the viewport, as a region, while its content overflows. That is the only time Base UI puts the viewport in the tab order, so a keyboard reader who lands on it hears what it scrolls; with nothing to scroll it is neither named nor focusable. And its focus treatment is the kit's 2px accent outline, where shadcn's is a half-strength halo that falls under 3:1 on the card under the coloured palettes — drawn on the root, inside its edge, while the viewport has keyboard focus, so a mask on the viewport cannot clip it away and an ancestor that clips its overflow cannot cut it. When everything inside is focusable already — a list of links or buttons, which scrolls itself into view as each takes the focus — pass focusableViewport={false}, and the viewport is neither a tab stop nor a region. Give the root a height, or a max-height on the root and the viewport, and it scrolls what is inside; add a horizontal ScrollBar for content that scrolls sideways. Base UI keeps how far the viewport is from each edge in --scroll-area-overflow-{x,y}-{start,end}, which a mask can read to fade the edges.
Install
npx shadcn@latest add @vibra/scroll-areaNeeds the @vibra registry in your components.json — set it up once.
Examples
import { ScrollArea } from "@/components/ui/scroll-area"
const DEPLOYS = [
{ sha: "5c84c71", branch: "feat/notification-center", when: "Sep 4, 15:27" },
{ sha: "bf0cfbb", branch: "main", when: "Sep 4, 15:26" },
{ sha: "787ae2d", branch: "fix/webhook-retry", when: "Sep 4, 09:50" },
{ sha: "59d8b4c", branch: "main", when: "Sep 4, 07:00" },
{ sha: "53e6c51", branch: "main", when: "Sep 1, 23:01" },
{ sha: "2855298", branch: "feat/checkout-redesign", when: "Sep 1, 19:12" },
{ sha: "0fdb506", branch: "main", when: "Sep 1, 16:03" },
{ sha: "01bae64", branch: "fix/pagination-off-by-one", when: "Sep 1, 06:15" },
]
// The name goes on the scroll area: while the list overflows, the scroller is a
// region with that name in the tab order, so the arrow keys scroll it.
export default function ScrollAreaDemo() {
return (
<ScrollArea aria-label="Recent deploys" className="h-44 w-full max-w-sm rounded-lg ring-1 ring-border">
<ul className="flex flex-col p-1">
{DEPLOYS.map((deploy) => (
<li key={deploy.sha} className="flex items-baseline gap-3 rounded-md px-2.5 py-2 text-sm">
<span className="w-16 shrink-0 font-mono text-xs text-muted-foreground">{deploy.sha}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{deploy.branch}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{deploy.when}</span>
</li>
))}
</ul>
</ScrollArea>
)
}Sideways
Saved views as chips on one line with a horizontal bar, keeping room for each chip's focus ring at the scroller's edge.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
const VIEWS = [
"All accounts",
"Enterprise",
"Trials ending this week",
"At risk",
"No owner",
"Over their seat limit",
"Annual plans",
"Churned in Q3",
]
// Saved views as a row of chips that scrolls sideways rather than wrapping
// under the table's toolbar. The chips stay on one line (w-max), the
// horizontal bar is asked for with ScrollBar, and the row keeps 4px of room
// so a chip's focus ring is not cut off by the scroller's edge. The chosen
// view sits on the selected plane and says so with aria-pressed.
export default function ScrollAreaHorizontal() {
const [view, setView] = React.useState(VIEWS[0])
const label = React.useId()
return (
<div className="flex w-full max-w-md flex-col gap-1.5">
<span id={label} className="text-xs font-medium text-muted-foreground">
Saved views
</span>
<ScrollArea aria-labelledby={label} className="w-full rounded-lg">
<div className="flex w-max gap-2 px-1 pt-1 pb-4">
{VIEWS.map((name) => (
<Button
key={name}
variant="outline"
size="sm"
aria-pressed={view === name}
className="rounded-full aria-pressed:border-transparent aria-pressed:bg-brand-muted"
onClick={() => setView(name)}
>
{name}
</Button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}Both ways
A wide table in one scroller with both bars and the corner, its titles stuck to the top; the caption names the region.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const INVOICES = [
{ number: "INV-100004", customer: "Meridian Group", country: "Canada", plan: "Starter, 9 seats", issued: "Aug 11, 2026", due: "Sep 9, 2026", status: "open", amount: 108 },
{ number: "INV-100005", customer: "Quarry Systems", country: "United States", plan: "Team, 16 seats", issued: "Nov 11, 2025", due: "Nov 29, 2025", status: "overdue", amount: 452.93 },
{ number: "INV-100008", customer: "Northwind Studio", country: "United Kingdom", plan: "Starter, 14 seats", issued: "Aug 10, 2026", due: "Sep 5, 2026", status: "open", amount: 168 },
{ number: "INV-100010", customer: "Alder Health", country: "United States", plan: "Team, 50 seats", issued: "Mar 9, 2026", due: "Mar 27, 2026", status: "overdue", amount: 1200 },
{ number: "INV-100012", customer: "Blue Harbor Analytics", country: "United States", plan: "Starter, 7 seats", issued: "Dec 6, 2025", due: "Jan 4, 2026", status: "overdue", amount: 84 },
{ number: "INV-100015", customer: "Brightline Digital", country: "Spain", plan: "Team, 11 seats", issued: "Dec 20, 2025", due: "Jan 17, 2026", status: "overdue", amount: 264 },
{ number: "INV-100018", customer: "Fathom Media", country: "India", plan: "Team, 32 seats", issued: "Aug 30, 2026", due: "Sep 14, 2026", status: "open", amount: 776.85 },
{ number: "INV-100023", customer: "Meridian Studio", country: "Germany", plan: "Starter, 10 seats", issued: "Aug 28, 2026", due: "Sep 21, 2026", status: "open", amount: 120 },
]
const COLUMNS = ["Invoice", "Customer", "Country", "Plan", "Issued", "Due", "Status"]
const STICKY = "sticky top-0 z-10 bg-card shadow-[inset_0_-1px_0_var(--rule)]"
// A table wider and longer than its box scrolls both ways inside one scroller:
// both bars are drawn, the corner fills where they meet, and the titles stick
// to the top on an opaque plane so rows never show through them. The table's
// own sideways scroller is switched off, so there is one scroller, not two, and
// the table's caption names both the table and the region it scrolls in.
export default function ScrollAreaTable() {
const caption = React.useId()
return (
<ScrollArea
aria-labelledby={caption}
className="h-44 w-full max-w-xl rounded-lg bg-card ring-1 ring-border [&_[data-slot=table-container]]:overflow-visible"
>
<Table className="min-w-[48rem]">
<TableCaption id={caption} className="sr-only">
Open invoices
</TableCaption>
<TableHeader>
<TableRow className="hover:bg-transparent">
{COLUMNS.map((column) => (
<TableHead key={column} className={STICKY}>
{column}
</TableHead>
))}
<TableHead className={`${STICKY} text-end`}>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((invoice) => (
<TableRow key={invoice.number}>
<TableCell className="font-mono text-xs">{invoice.number}</TableCell>
<TableCell className="font-medium">{invoice.customer}</TableCell>
<TableCell className="text-muted-foreground">{invoice.country}</TableCell>
<TableCell>{invoice.plan}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{invoice.issued}</TableCell>
<TableCell className="tabular-nums">{invoice.due}</TableCell>
<TableCell>
<StatusBadge status={invoice.status} size="sm" />
</TableCell>
<TableCell className="text-end tabular-nums">{formatCurrency(invoice.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}Grouped, with sticky headings
Payments by week, each week's heading holding at the top of the scroller until the next one pushes it off.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { ScrollArea } from "@/components/ui/scroll-area"
const WEEKS = [
{
label: "This week",
payments: [
{ invoice: "INV-100203", customer: "Granite Retail", amount: 120 },
{ invoice: "INV-100071", customer: "Wavelength Retail", amount: 18_432 },
],
},
{
label: "Last week",
payments: [
{ invoice: "INV-100120", customer: "Lakeside Analytics", amount: 480 },
{ invoice: "INV-100006", customer: "Lakeside Partners", amount: 312 },
{ invoice: "INV-100140", customer: "Alder Robotics", amount: 864 },
],
},
{
label: "Aug 18 – 24",
payments: [
{ invoice: "INV-100089", customer: "Silverpine Robotics", amount: 648 },
{ invoice: "INV-100270", customer: "Foxglove Group", amount: 10_608 },
{ invoice: "INV-100084", customer: "Ironwood Analytics", amount: 421.35 },
{ invoice: "INV-100251", customer: "Silverpine Group", amount: 9504 },
],
},
]
// Payments grouped by week, each week's title sticking to the top of the
// scroller until the next week pushes it off, so a row is never read without
// its week. The titles are headings on an opaque plane; each week is a list
// named by its heading.
export default function ScrollAreaSticky() {
const id = React.useId()
return (
<ScrollArea aria-label="Payments received" className="h-44 w-full max-w-sm rounded-lg bg-card ring-1 ring-border">
{WEEKS.map((week, index) => (
<section key={week.label} aria-labelledby={`${id}-${index}`}>
<h3
id={`${id}-${index}`}
className="sticky top-0 z-10 bg-surface px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-[inset_0_-1px_0_var(--border)]"
>
{week.label}
</h3>
<ul aria-labelledby={`${id}-${index}`} className="flex flex-col px-1 py-1">
{week.payments.map((payment) => (
<li key={payment.invoice} className="flex items-baseline gap-3 px-2 py-1.5 text-sm">
<span className="min-w-0 flex-1 truncate">{payment.customer}</span>
<span className="font-mono text-xs text-muted-foreground max-sm:hidden">{payment.invoice}</span>
<span className="w-20 text-end tabular-nums">{formatCurrency(payment.amount)}</span>
</li>
))}
</ul>
</section>
))}
</ScrollArea>
)
}Fading edges
A mask fades the edges where there is more to read, from Base UI's overflow variables; nothing is hidden from a screen reader.
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
const RELEASES = [
{ version: "4.3.0", date: "Aug 31", title: "Passkeys, and a faster orders table", summary: "Sign in without a password, and page through a hundred thousand orders without waiting." },
{ version: "4.2.1", date: "Jul 30", title: "Invoice rounding", summary: "A rounding fix for prorated lines, and two smaller corrections." },
{ version: "4.2.0", date: "Jul 4", title: "Scheduled reports", summary: "Have a saved view delivered by email, daily, weekly or monthly." },
{ version: "4.1.0", date: "Jun 21", title: "Webhook delivery log", summary: "Every attempt, its status code and its response body, kept for seven days." },
{ version: "4.0.0", date: "Jun 7", title: "The new shell", summary: "A rebuilt navigation, a command palette, and one breaking change to the API." },
{ version: "3.9.2", date: "May 18", title: "Import stability", summary: "Large imports no longer time out at the mapping step." },
]
// The edges fade where there is more to read: the top once the reader has
// scrolled, the bottom until the last entry. Base UI keeps how far the
// scroller is from each edge in CSS variables on the viewport, and the mask
// reads them. A mask changes what is painted, never what is there, so every
// entry is still read in full. It would clip the viewport's own focus ring,
// which is why the kit draws the ring on the scroll area around it.
const FADE =
"[&>[data-slot=scroll-area-viewport]]:[mask-image:linear-gradient(to_bottom,transparent,#000_min(2.5rem,var(--scroll-area-overflow-y-start)),#000_calc(100%-min(2.5rem,var(--scroll-area-overflow-y-end))),transparent)]"
export default function ScrollAreaFades() {
return (
<ScrollArea aria-label="What's new" className={cn("h-44 w-full max-w-sm rounded-md", FADE)}>
<ol className="flex flex-col gap-4 py-1 pe-4">
{RELEASES.map((release) => (
<li key={release.version} className="flex flex-col gap-0.5">
<span className="flex items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-mono">{release.version}</span>
{release.date}
</span>
<span className="text-sm font-medium">{release.title}</span>
<span className="text-sm text-muted-foreground">{release.summary}</span>
</li>
))}
</ol>
</ScrollArea>
)
}In a popover
A team list scrolling inside the panel is a named region in the tab order only while it overflows; filter it to fit and it is neither.
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
const TEAM = [
"Sonia Keller", "Kwame Halvorsen", "Farid Meyer", "Aisha Nakamura", "Hassan Ortiz", "Hassan Gallo",
"Farid Pereira", "Bruno Halvorsen", "Iris Brandt", "Greta Eriksen", "Nadia Larsen", "Camille Meyer",
"Nadia Meyer", "Esther Brandt", "Farid Imani", "Farid Halvorsen", "Hassan Costa", "Dmitri Farrow",
]
const initials = (name: string) => name.split(" ").map((part) => part[0]).join("")
// The team is longer than the panel, so the list scrolls inside it — and while
// it does, the scroller is a named region in the tab order. Type "Farid" and
// the list fits: then it is neither, because there is nothing to scroll. The
// chosen teammate is on the selected plane; choosing one closes the panel and
// hands the focus back to the button that opened it.
export default function ScrollAreaPopover() {
const [open, setOpen] = React.useState(false)
const [owner, setOwner] = React.useState("Hassan Ortiz")
const [query, setQuery] = React.useState("")
const shown = TEAM.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery("")
}}
>
<PopoverTrigger render={<Button variant="outline" aria-label={`Follow-up owner: ${owner}`} />}>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(owner)} alt="" />
<AvatarFallback>{initials(owner)}</AvatarFallback>
</Avatar>
{owner}
</PopoverTrigger>
<PopoverContent align="start" className="w-64">
<PopoverTitle>
Who follows up <span className="font-mono text-xs">INV-100010</span>?
</PopoverTitle>
<Input
type="search"
aria-label="Find a teammate"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="[&::-webkit-search-cancel-button]:appearance-none"
/>
<ScrollArea aria-label="Teammates" className="max-h-52 rounded-md *:data-[slot=scroll-area-viewport]:max-h-52">
<ul className="flex flex-col p-0.5">
{shown.map((name) => (
<li key={name}>
<button
type="button"
aria-pressed={name === owner}
onClick={() => {
setOwner(name)
setOpen(false)
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-start hover:bg-muted focus-ring-inset aria-pressed:bg-brand-muted"
>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{initials(name)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate">{name}</span>
{name === owner ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
</button>
</li>
))}
</ul>
</ScrollArea>
{shown.length === 0 ? <p className="px-2 text-muted-foreground">No teammate matches “{query.trim()}”.</p> : null}
</PopoverContent>
</Popover>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label / aria-labelledby | string | — | Names the scroller. It goes to the viewport, as a region, while the content overflows — which is when the viewport is in the tab order. |
| focusableViewport | boolean | true | Whether the viewport is a tab stop, and a named region, while it overflows. Turn it off when everything inside is focusable already. |
| ScrollBar.orientation | "vertical" | "horizontal" | "vertical" | The vertical bar comes with the scroll area; put a horizontal one inside it for content that scrolls sideways. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
"aria-label": label,
"aria-labelledby": labelledBy,
focusableViewport = true,
...props
}: ScrollAreaPrimitive.Root.Props & {
/**
* Whether the viewport is a tab stop while it overflows, as Base UI makes
* it. Turn it off when everything inside is focusable already — a list of
* links or buttons, whose own focus scrolls it into view — so the viewport
* is not one more stop in front of them, and names nothing.
*/
focusableViewport?: boolean
}) {
const named = focusableViewport && (label !== undefined || labelledBy !== undefined)
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
// The focus outline is drawn on the root, inside its edge: a mask on the
// viewport (the fades pattern) clipped the viewport's own outline away
// entirely, and an ancestor that clips its overflow — a popover, a card
// — cannot cut an inset one.
className={cn("relative has-[>[data-slot=scroll-area-viewport]:focus-visible]:focus-outline-inset", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none"
// Base UI puts the viewport in the tab order only while its content
// overflows, and that is when a keyboard reader lands on it — so that
// is when it becomes a region carrying the scroll area's name. A name
// on the root sat on a box nobody can focus.
render={(viewportProps, state) => (
<div
{...viewportProps}
{...(focusableViewport ? null : { tabIndex: -1 })}
{...(named && (state.hasOverflowX || state.hasOverflowY)
? { role: "region", "aria-label": label, "aria-labelledby": labelledBy }
: null)}
/>
)}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-s data-vertical:border-s-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }