Scroll area
Radix ScrollArea wrapper with thin native-like scrollbars in Yuno tokens (w-2.5 track, bg-border thumb, rounded-full). Auto-hides when there's nothing to scroll. Renders vertical only by default; add a second <ScrollBar orientation='horizontal' /> for horizontal or both axes.
Anatomy
Four parts: the Root (positioning + overflow-hidden), the Viewport (what actually scrolls), the ScrollBar (track + thumb), and — when both axes scroll — the Corner where the two scrollbars meet.
- 1ScrollArea (Root)
Positioned relative + overflow-hidden. Set the container size via className (h-72 w-48, max-h-96, etc.) — without a bounded height/width, the scrollbar never appears.
- 2Viewport
h-full w-full rounded-[inherit]. Children live inside this — the actual scrollable canvas. Radix wraps it so the native scrollbar can be hidden.
- 3ScrollBar
The custom scrollbar track. w-2.5 (10px) with a border-l-transparent hairline; renders vertical by default. For horizontal scroll, add another <ScrollBar orientation='horizontal' />.
- 4Thumb (inside ScrollBar)
flex-1 rounded-full bg-border. The draggable indicator. Muted intentionally so it doesn't compete with content.
- 5Corner
The 10×10 square where a vertical and a horizontal scrollbar meet. Only visible when both scrollbars are rendered simultaneously.
Variants
One primitive, three practical arrangements: vertical only (default), horizontal only, and both axes. Add a ScrollBar per axis you want.
<ScrollArea className="h-72 w-48 rounded-md border">
<div className="p-4">
<div className="pb-2 text-sm font-medium">Tags</div>
{tags.map((tag, i) => (
<React.Fragment key={tag}>
{i > 0 && <Separator className="my-2" />}
<div className="text-sm">{tag}</div>
</React.Fragment>
))}
</div>
</ScrollArea><ScrollArea className="w-96 whitespace-nowrap rounded-md border">
<div className="flex w-max gap-4 p-4">
{photos.map((photo) => (
<figure key={photo.author} className="shrink-0">
<div className="overflow-hidden rounded-md">
<img src={photo.src} className="aspect-[3/4] w-40 object-cover" />
</div>
<figcaption className="pt-2 text-xs text-muted-foreground">
Photo by <span className="font-semibold text-foreground">{photo.author}</span>
</figcaption>
</figure>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea><ScrollArea className="h-56 w-96 rounded-md border">
<div className="w-[720px] p-4">
{/* wide + tall content that scrolls both axes */}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>States
Scrollbar states: idle (hidden when content fits; auto-appears on hover / touch when scrollable — the 'hover' auto-hide behavior can be overridden with type='always'), hover (thumb picks up bg-border tone), dragging (thumb tracks the pointer while user drags).
Recipes
Ready-to-copy compositions covering the most common Yuno usages of this atom.
The Figma spec pattern: a short vertical list with a header + Separator between rows. Height-constrained (h-72 w-48) so the scrollbar appears when the list overflows.
<ScrollArea className="h-72 w-48 rounded-md border">
<div className="p-4">
<div className="pb-2 text-sm font-medium">Tags</div>
{tags.map((tag, i) => (
<React.Fragment key={tag}>
{i > 0 && <Separator className="my-2" />}
<div className="text-sm">{tag}</div>
</React.Fragment>
))}
</div>
</ScrollArea>Horizontal-only scroll: a row of aspect-ratio figures inside a fixed-width container. Add a second ScrollBar with orientation='horizontal' — the vertical one auto-hides when there's no vertical overflow.
<ScrollArea className="w-96 whitespace-nowrap rounded-md border">
<div className="flex w-max gap-4 p-4">
{photos.map((photo) => (
<figure key={photo.author} className="shrink-0">
<img src={photo.src} className="aspect-[3/4] w-40 rounded-md object-cover" />
<figcaption className="pt-2 text-xs">Photo by <b>{photo.author}</b></figcaption>
</figure>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>A common Dashboard pattern: a filter or account picker inside a Popover shouldn't push the layout when the list grows. Wrap the list in ScrollArea with a max-h to cap the popover height.
<PopoverContent className="w-64 p-0">
<ScrollArea className="max-h-60">
<div className="p-2">
{accounts.map((a) => (
<button
key={a.id}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
>
{a.name}
</button>
))}
</div>
</ScrollArea>
</PopoverContent>Wide table on a narrow viewport: wrap the Table in a horizontal-only ScrollArea so the layout doesn't force horizontal page scroll.
<ScrollArea className="w-full whitespace-nowrap rounded-md border">
<Table className="w-max min-w-full">
{/* many columns */}
</Table>
<ScrollBar orientation="horizontal" />
</ScrollArea>Import
Two exports from @/components/ui/scroll-area.
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";Props
ScrollArea and ScrollBar forward all Radix props. The most useful ones for prototypes:
| Prop | Type | Default | Description |
|---|---|---|---|
| ScrollArea.className | string | — | Merged onto the Root. This is where you set the bounded size (h-*, w-*, max-h-*). Also common: rounded-md border to give the region a surface. |
| ScrollArea.type | "auto" | "always" | "scroll" | "hover" | "hover" | Radix scrollbar visibility strategy. 'hover' auto-hides until the user hovers; 'always' keeps the scrollbar visible; 'scroll' shows only while scrolling; 'auto' matches the OS setting. |
| ScrollArea.scrollHideDelay | number | 600 | ms before the scrollbar hides after scrolling stops. Only relevant for type='scroll' / 'hover'. |
| ScrollArea.dir | "ltr" | "rtl" | — | Reading direction. Forwarded to Radix; affects which side the vertical scrollbar renders on. |
| ScrollBar.orientation | "vertical" | "horizontal" | "vertical" | Which axis this scrollbar controls. Vertical is rendered automatically by <ScrollArea>. Add a second <ScrollBar orientation='horizontal' /> as a child of ScrollArea when you also need horizontal. |
| ScrollBar.className | string | — | Merged onto the scrollbar track. Rarely needed — the DS tokens (w-2.5 / bg-border thumb) already match. Avoid overriding colors. |
When to use
- Constrained regions inside Cards, Sheets, Popovers or Dialogs where a native browser scrollbar would look inconsistent.
- Horizontal scroll surfaces (tables, image rows, tag rows) where you want the same thin scrollbar as verticals.
- Any list that should scroll independently without moving the surrounding page.
When not to use
- Page-level scroll — the browser (and the kit's docked shell) already handles that.
- A single overflow-x row where a plain overflow-x-auto is enough and no custom scrollbar is needed.
- Content the user really needs to see all of (accessibility) — provide a way to expand or paginate, not just scroll.
Usage
- Bound the Root with an explicit height / width / max-*.
- Add one ScrollBar per axis you actually need — omit the second one to keep the corner clean.
- Keep the thumb color at bg-border — it should feel like part of the surface, not a UI element.
- Wrap the immediate content in the same padding you would use on a Card (p-4 default, p-2 dense).
- Don't nest ScrollAreas inside each other — the second scrollbar becomes unreachable.
- Don't rely on ScrollArea for critical content — long lists still need pagination or 'load more'.
- Don't override the thumb to a bright color — it competes with the content.
- Don't wrap page-level content in a ScrollArea — the docked shell already scrolls internally.
Related
Cross-links to atoms and patterns you may reach for next.
- SheetSheets have their own scroll — only wrap inner content in ScrollArea when you need a nested, capped region.
- PopoverCap Popover content growth by wrapping the list in a max-h ScrollArea.
- TableWrap wide tables in a horizontal ScrollArea so the page itself doesn't force horizontal scroll.
- CommandCommand palette lists already use their own scroll — you don't need a ScrollArea inside.