Siteplane UI

Scroll Area

Constrain overflowing content with styled scrollbars.

Markdown
"use client";

import { ScrollArea } from "@/siteplane/components/scroll-area";

const tags = Array.from(
  { length: 50 },
  (_, index) => `v1.2.0-beta.${50 - index}`,
);

export default function Example() {
  return (
    <div className="h-72 w-48 overflow-hidden rounded-lg border">
      <ScrollArea aria-label="Tags">
        <div className="p-4">
          <h3 className="mb-4 font-medium text-sm leading-none">Tags</h3>
          <div className="flex flex-col gap-2">
            {tags.map((tag) => (
              <div className="text-sm" key={tag}>
                {tag}
              </div>
            ))}
          </div>
        </div>
      </ScrollArea>
    </div>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/scroll-area

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/badge

Usage

import { ScrollArea } from "@/siteplane/components/scroll-area";

<div className="h-40">
  <ScrollArea>Content</ScrollArea>
</div>;

Fade the edges

Set scrollFade to fade the edges of the viewport where more content waits. The top fades once the content is scrolled down, the bottom while more follows, and the left and right edges do the same. An edge with nothing beyond it stays sharp, and the fade grows with the distance left to scroll, up to 1.5rem. It is off by default on ScrollArea itself. SidebarContent and the lists of Combobox and Autocomplete always fade, and the panels of Dialog, Sheet and Drawer fade unless you pass scrollFade={false}.

"use client";

import { ScrollArea } from "@/siteplane/components/scroll-area";

const activity = [
  {
    id: 1,
    text: "Ben Carter commented on Website redesign",
    time: "2 min ago",
  },
  {
    id: 2,
    text: "Jonas Weber closed Fix invoice rounding",
    time: "14 min ago",
  },
  { id: 3, text: "Priya Shah uploaded brand-guidelines.pdf", time: "1 h ago" },
  { id: 4, text: "Kai Fischer moved Mobile app to Review", time: "2 h ago" },
  { id: 5, text: "Sofia Nguyen invited two teammates", time: "3 h ago" },
  { id: 6, text: "Ben Carter archived Q2 planning", time: "5 h ago" },
  { id: 7, text: "Jonas Weber opened Billing export bug", time: "Yesterday" },
  { id: 8, text: "Priya Shah joined the Design team", time: "Yesterday" },
  { id: 9, text: "Kai Fischer renamed Data migration", time: "2 days ago" },
  {
    id: 10,
    text: "Sofia Nguyen changed the workspace logo",
    time: "3 days ago",
  },
];

export default function Example() {
  return (
    <div className="h-64 w-full max-w-md overflow-hidden rounded-lg border">
      <ScrollArea aria-label="Recent activity" scrollFade>
        <ul className="divide-y px-4">
          {activity.map((item) => (
            <li
              className="flex items-baseline justify-between gap-4 py-3 text-sm"
              key={item.id}
            >
              <span>{item.text}</span>
              <span className="shrink-0 text-muted-foreground text-xs">
                {item.time}
              </span>
            </li>
          ))}
        </ul>
      </ScrollArea>
    </div>
  );
}

Horizontal scrolling

Give the content an intrinsic width, such as w-max, so it scrolls horizontally. scrollbarGutter reserves space so the scrollbar doesn't cover the content, and scrollFade fades the edge that still has content beyond it.

"use client";

import { Badge } from "@/siteplane/components/badge";
import { ScrollArea } from "@/siteplane/components/scroll-area";

const topics = [
  "Design",
  "Frontend",
  "Backend",
  "Infrastructure",
  "Research",
  "Billing",
  "Support",
  "Growth",
  "Security",
  "Analytics",
];

export default function Example() {
  return (
    <div className="w-full max-w-96 overflow-hidden rounded-lg border">
      <ScrollArea aria-label="Topics" scrollFade scrollbarGutter>
        <div className="flex w-max gap-2 p-4">
          {topics.map((topic) => (
            <Badge key={topic} variant="outline">
              {topic}
            </Badge>
          ))}
        </div>
      </ScrollArea>
    </div>
  );
}

Both axes

Content that is wider and taller than the viewport scrolls in both directions. With scrollFade, each edge fades on its own.

"use client";

import { ScrollArea } from "@/siteplane/components/scroll-area";

export default function Example() {
  return (
    <div className="h-64 w-full max-w-80 overflow-hidden rounded-lg border">
      <ScrollArea aria-label="Release notes" scrollFade>
        <div className="flex w-[36rem] flex-col gap-3 p-4 text-sm">
          <h3 className="font-medium">Release notes</h3>
          <p>
            Projects can now be archived from the project list. Archived
            projects keep their history and files and can be restored at any
            time by a workspace owner.
          </p>
          <p>
            Invoices include a line for every seat that was active during the
            billing period. Seats added in the middle of a period are charged
            proportionally on the next invoice.
          </p>
          <p>
            Search now matches project descriptions and comments in addition to
            project names. Results are grouped by project and sorted by the most
            recent activity.
          </p>
          <p>
            Workspace owners can require two-factor authentication for all
            members. Members without it are asked to set it up the next time
            they sign in.
          </p>
        </div>
      </ScrollArea>
    </div>
  );
}

Fill the viewport

Set fill when the content is a flex column that should stretch to the viewport height, for example to pin a footer with mt-auto.

"use client";

import { ScrollArea } from "@/siteplane/components/scroll-area";

const projects = [
  { href: "#website-redesign", name: "Website redesign" },
  { href: "#mobile-app", name: "Mobile app" },
  { href: "#data-migration", name: "Data migration" },
  { href: "#customer-portal", name: "Customer portal" },
];

export default function Example() {
  return (
    <div className="flex h-64 w-56 flex-col overflow-hidden rounded-lg border">
      <ScrollArea fill>
        <div className="flex h-full flex-col gap-2 p-2 text-sm">
          <nav aria-label="Projects" className="flex flex-col gap-1">
            {projects.map((project) => (
              <a
                className="rounded-md px-2 py-1.5 hover:bg-accent"
                href={project.href}
                key={project.href}
              >
                {project.name}
              </a>
            ))}
          </nav>
          <footer className="mt-auto border-t px-2 pt-2 text-muted-foreground text-xs">
            4 active projects
          </footer>
        </div>
      </ScrollArea>
    </div>
  );
}

API reference

Component properties
PropTypeDefaultDescription
scrollFadebooleanfalseFades each edge that has more content beyond it (top, bottom, left, right) by up to 1.5rem, growing with the distance left to scroll. An edge with nothing beyond it stays sharp.
scrollbarauto | hiddenautoRender both automatic scrollbars or neither.
scrollbarGutterbooleanfalseAdd viewport padding when overflow exists.
fillbooleanfalseContent gets full viewport size.
clampContentMinWidthbooleantrueContent min-width is set to zero; false preserves intrinsic sizing.
aria-label / aria-labelledbystringunsetNames the scrollable viewport, which then becomes a labeled region.
ScrollBar.orientationhorizontal | verticalverticalLow-level scrollbar direction.

ScrollArea wraps Base UI Root and automatically includes Viewport, Content, vertical/horizontal ScrollBar and Corner. ScrollBar automatically includes Thumb. ScrollAreaPrimitive exposes Base UI Scroll Area. Other root props and refs are inherited; className is a string. There is no separate viewportProps or contentProps bag. aria-label and aria-labelledby go to the scrollable viewport; other root props, including other ARIA attributes, apply to the root element, which Base UI renders with role="presentation". Use the lower-level Base UI parts only if this wrapper cannot represent your use case.

Read the installed source.

Usage notes

The viewport joins the tab order while its content overflows, so keyboard users can focus it and scroll with the arrow keys. Name it with aria-label or aria-labelledby so screen readers announce what it contains. scrollbar="hidden" hides the scrollbars but not the scrolling; keep overflow discoverable. Root scroll handlers are not a dedicated viewport callback API. Constrain the parent size; setting height only on oversized child content does not create a usable viewport.