Scroll Area
A capped scroll region with overlay scrollbars and progressive edge fade.
Built on Base UI ScrollArea — native scroll still does the work, so
keyboard scrolling and momentum are unchanged. Approaching rows blur (0 →
1px) and wash into the surface; strength tracks how far content has scrolled
under the edge.
Import
import { ScrollArea } from "@photon-ai/pho-ui/components/scroll-area";
Basic
Give ScrollArea.Root a fixed size, put the content in Viewport → Content,
and add a Scrollbar → Thumb. The bar sits over the content and fades in on
hover or while scrolling, so it never shifts layout. Edges blur and wash in as
you scroll.
<ScrollArea.Root className="h-44 w-72">
<ScrollArea.Viewport>
<ScrollArea.Content className="p-3">…</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
Both axes
Add a Scrollbar per axis — the default orientation is vertical; set
orientation="horizontal" for the second. A Corner fills the square where the
two bars meet.
<ScrollArea.Root className="h-48 w-80">
<ScrollArea.Viewport>
<ScrollArea.Content className="p-3">…</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Corner />
</ScrollArea.Root>
Anatomy
- ScrollArea.Root — the clipping container; give it a fixed size and it owns the overflow/scroll state. Progressive top/bottom edge fades live here.
- ScrollArea.Viewport — the scrollable box (the element that actually scrolls and takes keyboard focus)
- ScrollArea.Content — wraps the scrolling content; its size drives whether each axis overflows
- ScrollArea.Scrollbar → Thumb — one bar and its draggable thumb per axis;
shows on
data-hovering/data-scrolling - ScrollArea.Corner — the filler between a horizontal and vertical bar
Props
Every part renders a <div> and forwards className plus native <div>
attributes. The parts that add their own props:
- ScrollArea.Root
- overflowEdgeThreshold —
number | { xStart, xEnd, yStart, yEnd }(default0) — pixels of scroll before thedata-overflow-*edge attributes flip; use it to gate edge fades
- overflowEdgeThreshold —
- ScrollArea.Scrollbar
- orientation —
vertical·horizontal(defaultvertical) - keepMounted —
boolean(defaultfalse) — keep the bar in the DOM even when that axis isn’t scrollable
- orientation —
- ScrollArea.Viewport / Content / Thumb / Corner —
classNameand native<div>attributes only
Root exposes overflow state as data-* attributes (data-has-overflow-x,
data-overflow-y-end, …) and the active scrollbar carries data-hovering,
data-scrolling, and data-orientation for styling.
Accessibility
- Semantics — the structural parts (
Root,Viewport,Content) carryrole="presentation", while the scrollbars and thumbs set no role at all: they’re a visual skin over native overflow, not ARIA scrollbar widgets, so they add no extra semantics for assistive tech. - Focus — the focused viewport shows a focus ring (
focus-visible); the scrollbars themselves are never focusable. - Labelling — if the scrolling region is a meaningful landmark (a log, a
list), label the
Root(or the element you scope focus to) so the focus stop is announced — the component doesn’t invent a name for it.
Keyboard: when the content overflows, ScrollArea.Viewport is placed in the tab
order (tabIndex={0}) so a keyboard user can focus the region and scroll it; a
non-scrollable viewport is removed from the tab order (tabIndex={-1}). Once
focused it scrolls with the platform’s native keys:
↑/↓/←/→— scroll by linePage Up/Page Down— scroll by pageHome/End— jump to the start / end
Best practices
- Don’t put
opacityon an ancestor of the edges — it disablesbackdrop-filter. - Use a scroll area when you want consistent, branded scrollbars or an overlay bar that doesn’t shift layout — not as a substitute for good page flow.
- Always constrain
Root’s height (or width); without a fixed size there’s nothing to scroll and the bar never appears. - Don’t trap the page’s main scroll inside one; reserve it for self-contained panels (lists, code blocks, sidebars).
- Add a second
Scrollbarand aCorneronly when content genuinely overflows both axes — prefer wrapping or truncating over a two-axis scroll when you can.