Scroll Area

A scrollable region with custom-styled scrollbars that match the design system. Content must be explicitly sized.

Vertical

Tags

v1.2.0-beta.50
v1.2.0-beta.49
v1.2.0-beta.48
v1.2.0-beta.47
v1.2.0-beta.46
v1.2.0-beta.45
v1.2.0-beta.44
v1.2.0-beta.43
v1.2.0-beta.42
v1.2.0-beta.41
v1.2.0-beta.40
v1.2.0-beta.39
v1.2.0-beta.38
v1.2.0-beta.37
v1.2.0-beta.36
v1.2.0-beta.35
v1.2.0-beta.34
v1.2.0-beta.33
v1.2.0-beta.32
v1.2.0-beta.31
v1.2.0-beta.30
v1.2.0-beta.29
v1.2.0-beta.28
v1.2.0-beta.27
v1.2.0-beta.26
v1.2.0-beta.25
v1.2.0-beta.24
v1.2.0-beta.23
v1.2.0-beta.22
v1.2.0-beta.21
v1.2.0-beta.20
v1.2.0-beta.19
v1.2.0-beta.18
v1.2.0-beta.17
v1.2.0-beta.16
v1.2.0-beta.15
v1.2.0-beta.14
v1.2.0-beta.13
v1.2.0-beta.12
v1.2.0-beta.11
v1.2.0-beta.10
v1.2.0-beta.9
v1.2.0-beta.8
v1.2.0-beta.7
v1.2.0-beta.6
v1.2.0-beta.5
v1.2.0-beta.4
v1.2.0-beta.3
v1.2.0-beta.2
v1.2.0-beta.1

Horizontal

Photo by Ornella Binni
Photo by Tom Byrom
Photo by Vladimir Malyavko
Photo by Yuki Aoki
Photo by Dmitri Kolpakov
Photo by Bruno Martins

Installation

pnpm
pnpm dlx @hex-core/cli add scroll-area

Horizontal scroll thumbnails

Render a horizontal flex row inside ScrollArea. The wrapper auto-mounts both vertical and horizontal scrollbars; horizontal overflow comes from `whitespace-nowrap` plus the inner `flex w-max` row.

tsx
<ScrollArea className="w-full max-w-md whitespace-nowrap rounded-md border">
  <div className="flex w-max gap-3 p-3">
    {photos.map((photo) => (
      <figure key={photo.id} className="shrink-0">
        <img
          src={photo.thumb}
          alt={photo.alt}
          className="h-28 w-40 rounded-md object-cover"
        />
        <figcaption className="pt-2 text-xs text-muted-foreground">{photo.caption}</figcaption>
      </figure>
    ))}
  </div>
</ScrollArea>

Auto-scroll to bottom (chat messages)

Pin the viewport to the latest message. Use a ref on the ScrollArea root, then scroll the inner Radix viewport (selected via [data-radix-scroll-area-viewport]) on every messages change.

tsx
import { useEffect, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";

interface Message {
  id: string;
  author: string;
  body: string;
}

export function ChatLog({ messages }: { messages: Message[] }) {
  const rootRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const viewport = rootRef.current?.querySelector<HTMLDivElement>(
      "[data-radix-scroll-area-viewport]",
    );
    if (!viewport) return;
    viewport.scrollTop = viewport.scrollHeight;
  }, [messages]);

  return (
    <ScrollArea ref={rootRef} className="h-72 w-full rounded-md border">
      <ul className="space-y-2 p-3">
        {messages.map((m) => (
          <li key={m.id} className="text-sm">
            <span className="font-medium">{m.author}:</span> {m.body}
          </li>
        ))}
      </ul>
    </ScrollArea>
  );
}

API Reference

PropTypeDefaultDescription
type
"auto" | "always" | "scroll" | "hover"hoverWhen scrollbars are visible
className
stringSet dimensions via Tailwind (e.g. h-72 w-48)
viewportTabIndex
number0tabIndex applied to the scroll viewport. Defaults to 0 so keyboard users can scroll without a pointer; pass -1 to skip the viewport in the tab order when wrapping decorative or already-keyboard-reachable content.

AI Guidance

When to use

Use when you need styled scrollbars that match the design system — sidebars, code blocks, large lists in dialogs. Must have explicit dimensions (height/width).

When not to use

Don't use for the whole page (use native browser scrollbars). Don't use for content that should grow freely (omit the ScrollArea and use overflow-auto directly).

Common mistakes

  • Forgetting to set height/width — scrollbars don't appear
  • Using for the whole page
  • Nesting ScrollAreas (confusing UX)
  • Wrapping decorative or already-keyboard-reachable content without setting viewportTabIndex={-1} — adds an unnecessary tab stop

Accessibility

The viewport is keyboard-focusable by default (viewportTabIndex=0) so users can scroll long content via arrow keys / PgUp / PgDn / Home / End without a pointer. Pass viewportTabIndex={-1} when the contents are already in the tab order or purely decorative. For very long lists, consider pagination or virtualization.

Related components

Token budget: 350