Skip to content

Scroll area

A styled scroll container with a quiet thumb on a narrow track.

Level
Utility
Category
Layout
Adoption
Not used yet
import { ScrollArea } from "@oration/canon/components/scroll-area";
packages/canon/src/components/scroll-area.tsx

Activity on Northwind Freight

import { ScrollArea } from "@oration/canon/components/scroll-area";import * as React from "react";export function Hero() {    const events = [        {            who: "Priya Raman",            what: "approved INV-20931 for $18,420.00",            when: "Today at 9:42 AM",        },        {            who: "Northwind Freight",            what: "uploaded a corrected W-9",            when: "Today at 8:15 AM",        },        {            who: "Jordan Lee",            what: "added INV-20931 to the Friday payment run",            when: "Yesterday at 4:58 PM",        },        {            who: "Aisha Bello",            what: "changed payment terms from Net 30 to Net 45",            when: "Yesterday at 2:10 PM",        },        {            who: "Remittance bot",            what: "emailed remittance advice for RMT-4410",            when: "Sep 25 at 9:14 AM",        },        {            who: "Tomás Ferreira",            what: "flagged INV-20877 as a duplicate",            when: "Sep 24 at 11:30 AM",        },        {            who: "Wen Zhou",            what: "matched INV-20862 to PO-7715",            when: "Sep 23 at 3:02 PM",        },        {            who: "Maya Okafor",            what: "raised the approval limit to $25,000",            when: "Sep 22 at 10:21 AM",        },        {            who: "Priya Raman",            what: "requested an updated W-9",            when: "Sep 21 at 1:47 PM",        },    ];    const headingId = React.useId();    return (        <div className="flex w-full max-w-sm flex-col rounded-xl bg-card shadow-border">            <h3                id={headingId}                className="border-b border-border px-4 py-2.5 text-sm font-medium text-foreground"            >                Activity on Northwind Freight            </h3>            <ScrollArea className="h-64 rounded-b-xl">                <ol                    aria-labelledby={headingId}                    className="flex flex-col px-4 py-1.5"                >                    {events.map((event) => (                        <li                            key={`${event.who}-${event.when}`}                            className="flex flex-col gap-0.5 py-2"                        >                            <span className="text-13 text-foreground">                                <span className="font-medium">{event.who}</span>{" "}                                {event.what}                            </span>                            <span className="text-xs text-muted-foreground">                                {event.when}                            </span>                        </li>                    ))}                </ol>            </ScrollArea>        </div>    );}

Usage#

ScrollArea wraps Base UI's scroll area: a viewport that scrolls natively, with a custom 10px vertical scrollbar whose thumb is drawn in --border. It gives a bounded panel, such as a long list in a popover or a fixed-height card, the same scrollbar in every browser and OS setting. Nothing in the product uses it yet; scrolling regions are plain overflow-y-auto with the thin global scrollbar. The usual mistake is giving it no height, so it never scrolls and the page grows instead.

When to use

  • A fixed-height list inside a card: an activity feed, recent remittances, a W-9 request log.
  • A long list inside a popover or menu-like panel, such as a supplier picker.
  • A region where the scrollbar must show regardless of the OS overlay-scrollbar setting.

When not to use

  • Page-level scrolling, sheets and dialogs. Let the document or the panel scroll natively.
  • Horizontal tab and view rows. They hide the scrollbar and fade the clipped edge. Use Overflow fade
  • Data grids with sticky headers and pinned columns. Use Data grid
  • Chat threads that stick to the bottom as messages arrive. Use Message scroller
  • The app sidebar. Use Sidebar

The Scroll Edge Rule

Clipped content shows its edge. ScrollArea doesn't fade or shadow its edges on its own; Base UI sets data-overflow-y-start and data-overflow-y-end on the root if a region needs one.

Anatomy#

  1. Root. relative, takes className (give it the height). data-slot="scroll-area".
  2. Viewport. Fills the root and scrolls natively. Corners inherit from the root. Focusable when it has overflow.
  3. Scrollbar. Vertical, 10px wide with 1px padding and a transparent left border. Unmounted when nothing overflows.
  4. Thumb. A rounded-full bg-border pill sized to the visible fraction. Drag it or click the track to jump.

Examples#

In a popover

A fixed h-56 list inside a popover. Right padding on the list keeps rows clear of the 10px scrollbar.

import { Button } from "@oration/canon/components/button";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { ScrollArea } from "@oration/canon/components/scroll-area";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, ChevronDownIcon } from "lucide-react";import * as React from "react";export function InPopover() {    const suppliers = [        "Bellweather Logistics",        "Cascade Print Works",        "Halcyon Packaging",        "Juniper Office Co.",        "Northwind Freight",        "Orchard Street Supply",        "Pacific Rim Metals",        "Redwood Janitorial",        "Summit Electrical",        "Tidewater Fuel",    ];    const [supplier, setSupplier] = React.useState("Northwind Freight");    const [open, setOpen] = React.useState(false);    return (        <Popover open={open} onOpenChange={setOpen}>            <PopoverTrigger                render={                    <Button                        type="button"                        variant="outline"                        className="w-56 justify-between"                    />                }            >                {supplier}                <ChevronDownIcon data-icon="inline-end" aria-hidden="true" />            </PopoverTrigger>            <PopoverContent align="start" className="w-56 gap-0 p-1">                <ScrollArea className="h-56">                    <ul aria-label="Suppliers" className="flex flex-col pr-2.5">                        {suppliers.map((name) => {                            const active = name === supplier;                            return (                                <li key={name}>                                    <button                                        type="button"                                        aria-pressed={active}                                        onClick={() => {                                            setSupplier(name);                                            setOpen(false);                                            toast.add({                                                title: `Showing invoices from ${name}`,                                            });                                        }}                                        className={cn(                                            "flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-left text-13 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40",                                            active                                                ? "font-medium text-foreground"                                                : "text-foreground",                                        )}                                    >                                        {name}                                        {active ? (                                            <CheckIcon                                                aria-hidden="true"                                                className="size-4 text-foreground"                                            />                                        ) : null}                                    </button>                                </li>                            );                        })}                    </ul>                </ScrollArea>            </PopoverContent>        </Popover>    );}

Scrollbar only when needed

Same height, different lengths. With nothing to scroll, the scrollbar isn't rendered and the viewport drops out of the tab order.

3 remittances
12 remittances
import { ScrollArea } from "@oration/canon/components/scroll-area";export function OnlyWhenNeeded() {    const short = ["RMT-4410", "RMT-4411", "RMT-4412"];    const long = Array.from(        { length: 12 },        (_, index) => `RMT-${4400 + index}`,    );    return (        <div className="grid w-full max-w-md grid-cols-2 gap-4">            {[                { label: "3 remittances", items: short },                { label: "12 remittances", items: long },            ].map((column) => (                <div key={column.label} className="flex flex-col gap-1.5">                    <span className="text-xs text-muted-foreground">                        {column.label}                    </span>                    <ScrollArea className="h-40 rounded-xl bg-card shadow-border">                        <ul                            aria-label={column.label}                            className="flex flex-col px-3 py-1.5"                        >                            {column.items.map((id) => (                                <li                                    key={id}                                    className="flex h-8 items-center font-mono text-xs text-foreground"                                >                                    {id}                                </li>                            ))}                        </ul>                    </ScrollArea>                </div>            ))}        </div>    );}

States#

States
StateTreatment
FitsNo overflow: the scrollbar isn't rendered and the viewport leaves the tab order.
ScrollableThe scrollbar and thumb show; the viewport is focusable.
Hovering and scrollingThe scrollbar gets data-hovering and data-scrolling, but draws nothing different today.
Focus visibleThe viewport shows a 3px ring at 50% and a 1px outline when focused from the keyboard.

Behavior#

  • Scrolling is native: wheel, trackpad, touch and keyboard all work, and scroll position survives re-renders.
  • Thumb size and position come from CSS variables Base UI writes on each scroll. Clicking the track jumps there; dragging the thumb scrolls proportionally.
  • The scrollbar sits at the inline end (inset-inline-end), so under an RTL direction it moves to the left.
  • Children render inside the viewport. ScrollArea always adds one vertical ScrollBar and a corner.
  • overflowEdgeThreshold sets how far content must scroll before the root's data-overflow-* edge attributes appear.

Do and don't#

Do. Give the root a height or max height: className="h-64".
Don't. Leave it unsized. It grows with its content, never scrolls, and the page scrolls instead.
Do. Keep native scrolling for pages, sheets and dialogs.
Don't. Wrap a whole page in ScrollArea. Sticky headers, scroll restoration and find-in-page anchoring get harder for no gain.

Content#

  • Give the region a heading or an accessible name so people know what scrolls: Activity on Northwind Freight.
  • Put the most recent or most relevant items first, so the region is useful before anyone scrolls.

Accessibility#

  • When content overflows, the viewport is in the tab order so keyboard users can scroll it; it has role="presentation", so name the list inside it.
  • The custom scrollbar is pointer-only. Keyboard and screen reader users scroll the viewport.
  • The thumb is drawn in --border on a transparent track, a low-contrast affordance; don't rely on it as the only sign there is more.
Keyboard interactions
KeysAction
TabFocuses the viewport when it has overflow.
ArrowUpArrowDownScrolls the focused viewport.
PageUpPageDownScrolls by a page.
HomeEndJumps to the top or bottom.

Design tokens#

Design tokens
TokenUsed for
--borderThumb fill
--ringViewport focus ring at 50% and outline
rounded-fullThumb shape

API reference#

ScrollArea

From @oration/canon/components/scroll-area.

Other props spread onto Base UI ScrollArea.Root (<div>).

Props of ScrollArea
PropTypeDefaultDescription
classNamestringNo defaultMerged after relative. Set the height here.
childrenReactNodeNo defaultRendered inside the viewport.
overflowEdgeThresholdnumber | Partial<{ xStart; xEnd; yStart; yEnd }>0Pixels to scroll before the data-overflow-* edge attributes are set.

ScrollBar

The styled scrollbar. ScrollArea already renders a vertical one.

Other props spread onto Base UI ScrollArea.Scrollbar (<div>).

Props of ScrollBar
PropTypeDefaultDescription
orientation"vertical" | "horizontal""vertical"10px wide when vertical, 10px tall when horizontal.
keepMountedbooleanfalseKeep the scrollbar in the DOM when nothing overflows.
classNamestringNo defaultMerged after the scrollbar classes.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Not used in apps/web. Scrolling regions use overflow-y-auto with the global thin scrollbar in --border-strong, while ScrollArea's thumb is --border, so the two would look different side by side.

The registry says the thumb widens on hover. The code doesn't: the track is always 10px and the thumb always bg-border.

There's no way to add a horizontal scrollbar through ScrollArea: it renders only a vertical ScrollBar, and children land inside the viewport.

The viewport focus ring is ring-[3px] ring-ring/50 plus a 1px outline; Canon's focus ring elsewhere is ring-3 ring-ring/40.