Photon Design System

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>

Resting inset

inset on Content is a resting inset in the scroll axis — a scroll view’s safe-area padding. The content stands clear of the ends at rest but scrolls past them to the true edge, where the edge fade and the scrollbar stay. insetTop / insetBottom set the ends separately and win over inset. This is not padding on Root: that would clip the content at the inset instead of letting it pass under.

<ScrollArea.Root className="h-44 w-72 rounded-base border border-pho-secondary">
  <ScrollArea.Viewport>
    <ScrollArea.Content inset={20} className="px-3">
      {releases.map(…)}
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</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
    • fade — boolean (default true) — progressive edge fade (blur + wash). false is for code surfaces only; CodeBlock is the only caller, so glyphs hard-clip at the border
    • overflowEdgeThreshold — number | { xStart, xEnd, yStart, yEnd } (default 0) — pixels of scroll before the data-overflow-* edge attributes flip; use it to gate edge fades
  • ScrollArea.Scrollbar
    • orientation — vertical · horizontal (default vertical)
    • keepMounted — boolean (default false) — keep the bar in the DOM even when that axis isn’t scrollable
  • ScrollArea.Content
    • inset — number | string — resting inset for both ends of the scroll axis; content scrolls past it to the true edge
    • insetTop / insetBottom — number | string — one end on its own; wins over inset
  • ScrollArea.Viewport / Thumb / Corner — className and 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) carry role="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 line
  • Page Up / Page Down — scroll by page
  • Home / End — jump to the start / end

Best practices

  • Don’t put opacity on an ancestor of the edges — it disables backdrop-filter.
  • Leave fade on. fade={false} is for code surfaces only (CodeBlock); everywhere else the edge blur is part of the scroll language.
  • 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).
  • Scrolling chains: once the viewport reaches its end (or has nothing to scroll), the wheel or swipe carries on into the parent, like a native scroll view. Only a popup that must hold the scroll (a listbox over the page) adds overscroll-contain to its own Viewport.
  • Add a second Scrollbar and a Corner only when content genuinely overflows both axes — prefer wrapping or truncating over a two-axis scroll when you can.