Gesso
Layout

Scroll area

A scrolling region with a slim scrollbar on the border color, for sidebar, list, and detail panes.

Scroll area gives one pane its own scrolling, so a sidebar, a list, and a detail view each scroll independently instead of the whole page moving. It wraps Radix Scroll Area, which hides the browser's scrollbar and draws a slim one in its place, with a thumb on bg-border-strong. Scrolling itself stays native, so wheel, touch, keyboard, and find in page work as they do anywhere else.

Issue GES-180

Issue GES-181

Issue GES-182

Issue GES-183

Issue GES-184

Issue GES-185

Issue GES-186

Issue GES-187

Issue GES-188

Issue GES-189

Issue GES-190

Issue GES-191

Issue GES-192

Issue GES-193

Issue GES-194

Issue GES-195

Usage

import { ScrollArea } from '@/components/ui/scroll-area';

<ScrollArea className="h-80">
  {/* long list */}
</ScrollArea>

The area needs a height to scroll against, so set one on it (h-80) or put it in a parent that has one, such as a flex column with min-h-0. A max-h-* on the area alone does not limit it, because the viewport fills the area and the area then grows with its content. Use one area per pane, and keep it off a pane that is as tall as its content. viewportProps go on the scrolling viewport rather than the area, such as { tabIndex: 0, "aria-label": "Activity" } for a pane with nothing focusable inside.

viewportRef gives you the viewport itself, the element that scrolls, for code that reads or sets its position, like opening a timeline a few weeks before today or keeping a log pinned to its end. A ref on ScrollArea lands on the outer area, which does not scroll.

const viewport = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
  viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [entries]);

<ScrollArea className="h-80" viewportRef={viewport}>
  {/* log entries */}
</ScrollArea>

Scrollbar visibility

The scrollbar shows while the pointer is over the area or while it scrolls, and fades out 600ms after. Radix calls this the hover type, and it is the default. Pass type to change it on the area, as on Radix Scroll Area. scroll shows the bar only while scrolling, auto shows it whenever the content overflows, and always shows it all the time. scrollHideDelay sets the fade-out delay in milliseconds.

The bar is 10px wide, and its thumb is a rounded bg-border-strong bar that follows the look's border color. The thumb can be dragged.

Scrolling sideways

orientation picks the bars the area draws, and the area scrolls only along an axis that has one. vertical is the default. horizontal is for a sideways strip, like a timeline or a row of cards, and both is for content that overflows both ways, like a wide canvas. Give sideways content a width of its own, such as w-max.

Cycle 1Cycle 2Cycle 3Cycle 4Cycle 5Cycle 6Cycle 7Cycle 8Cycle 9Cycle 10Cycle 11Cycle 12
import { ScrollArea } from '@/components/ui/scroll-area';

<ScrollArea orientation="horizontal" className="w-full whitespace-nowrap">
  <div className="flex w-max gap-4 p-6">{/* wide content */}</div>
</ScrollArea>

ScrollBar is still exported, for a bar you place yourself. The area draws its own from orientation, so add one only alongside an orientation that does not already draw it.

Props

Scroll area takes the props of Radix Scroll Area's root, such as type and scrollHideDelay, plus these.

PropTypeDefaultWhat it does
orientation"vertical" | "horizontal" | "both""vertical"Which bars to draw, and so which ways the area scrolls.
viewportRefRef<HTMLDivElement>A ref to the viewport, the element that scrolls.
viewportPropsRadix Viewport propsProps for the viewport, like tabIndex and aria-label.
classNamestringAdded to the outer area. Give it the height to scroll against.

Accessibility

  • The viewport is the browser's own scroll container, so the mouse wheel, touch, Page Up and Page Down, the arrow keys, and find in page all scroll it, and the content stays in the reading order.
  • The viewport shows a 2px ring on :focus-visible, and in forced colors that ring is repainted by the system. Radix does not give the viewport a tabindex, so whether Tab reaches it depends on the browser. Current Chrome focuses a scroll container that has no focusable content, and other browsers may not. When the content has nothing focusable, pass viewportProps={{ tabIndex: 0, "aria-label": "…" }} so every browser gives the keyboard a stop to scroll from, with a name.
  • The scrollbar and thumb are not exposed to assistive tech, and there is no role or label for them. Scrolling is announced through the content, as with a native scroller.
  • The thumb draws in CanvasText in forced colors, so it stays visible in Windows high contrast.
  • The scrollbar has touch-none, so a finger drag on the bar moves the thumb, and a drag on the content scrolls it natively.
  • The scrollbar has no color transition under reduced motion, and scrolling is not animated.

When to use something else

  • Native overflow (overflow-y-auto) when you do not need a styled bar. It is lighter, has no wrapper, and gets keyboard focus and the platform's scrollbar for free. Use Scroll area when the browser's bar clashes with the look, or when the same list should look alike on every platform.
  • Resizable when panes should be resizable. Its panels scroll natively, so place a Scroll area inside one when you want this bar.
  • Table for a wide table, which scrolls itself.

Install

shadcn add @gesso/scroll-area

On this page