Scroll area
A styled scroll container with a quiet thumb on a narrow track.
- Status
- Experimental
- Level
- Utility
- Category
- Layout
- Adoption
- Not used yet
import { ScrollArea } from "@oration/canon/components/scroll-area";packages/canon/src/components/scroll-area.tsxActivity 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
data-overflow-y-start and data-overflow-y-end on the root if a region needs one.Anatomy#
- Root.
relative, takesclassName(give it the height).data-slot="scroll-area". - Viewport. Fills the root and scrolls natively. Corners inherit from the root. Focusable when it has overflow.
- Scrollbar. Vertical, 10px wide with 1px padding and a transparent left border. Unmounted when nothing overflows.
- Thumb. A
rounded-full bg-borderpill 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.
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#
| State | Treatment |
|---|---|
| Fits | No overflow: the scrollbar isn't rendered and the viewport leaves the tab order. |
| Scrollable | The scrollbar and thumb show; the viewport is focusable. |
| Hovering and scrolling | The scrollbar gets data-hovering and data-scrolling, but draws nothing different today. |
| Focus visible | The 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.
ScrollAreaalways adds one verticalScrollBarand a corner. overflowEdgeThresholdsets how far content must scroll before the root'sdata-overflow-*edge attributes appear.
Do and don't#
className="h-64".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
--borderon a transparent track, a low-contrast affordance; don't rely on it as the only sign there is more.
| Keys | Action |
|---|---|
| Tab | Focuses the viewport when it has overflow. |
| ArrowUpArrowDown | Scrolls the focused viewport. |
| PageUpPageDown | Scrolls by a page. |
| HomeEnd | Jumps to the top or bottom. |
Design tokens#
| Token | Used for |
|---|---|
--border | Thumb fill |
--ring | Viewport focus ring at 50% and outline |
rounded-full | Thumb shape |
API reference#
ScrollArea
From @oration/canon/components/scroll-area.
Other props spread onto Base UI ScrollArea.Root (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after relative. Set the height here. |
children | ReactNode | No default | Rendered inside the viewport. |
overflowEdgeThreshold | number | Partial<{ xStart; xEnd; yStart; yEnd }> | 0 | Pixels 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | 10px wide when vertical, 10px tall when horizontal. |
keepMounted | boolean | false | Keep the scrollbar in the DOM when nothing overflows. |
className | string | No default | Merged 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.