← ui

scroll-area

ui

the one capped scroller: its ends fade, it wears thumbs when long, the page scrolls on

npx shadcn add @ag/scroll-area
Already installed? Preview an update
npx shadcn add @ag/scroll-area --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • New: ScrollArea, the one capped scroller, replacing scroll-fade. Its ends fade where there is more to show (the edge fade, 1rem, CAPPED_FADE); fade={false} only inside a glass panel, whose spill ends it. thumb adds overlay thumbs, laid over it from beside it so its fade never fades them. cover and coverEnd are a head and a foot laid over its ends: what it holds starts and ends clear of them and passes beneath them, a focused row stops clear of them, the thumbs run between them. render makes the scroller another element (Base UI's list in a select or a combobox). No padding of its own; at its ends the scroll goes on into the page. A data-slot given to it is kept.
  • Every capped vertical scroller in the registry is one now, so they end alike: dialog and sheet's bodies (their slots dialog-body and sheet-body kept), select's, people-picker's and chip-input's lists, table's choices, icon-picker, the gallery toolbar's settings, gallery-filters' sections, file-view's text, player's playlist, command and country-picker. Each of these items now depends on scroll-area. icon-picker hands the scroll on to the page at its ends, where it used to hold it.
Updated 2026-10-14 · release notes

also called scroll fade, fade mask, scroll edge, overflow fade, scrollable

shelf glass & surfaces

uses scrims, scroll-thumb

used by dialog, command, table, components, chip-input, people-picker, select, panel, country-picker, sheet, icon-picker, player

the demo above, verbatim

scroll-area.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import type { Material } from "@/registry/base-nova/ui/material"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"

// #region rows
// Enough rows to scroll in a capped column, so both ends have work to do.
const ROWS = Array.from({ length: 14 }, (_, i) => `request ${i + 1}`)
// #endregion

// #region list
// A short capped list: the ends fade where there is more to see, no thumb.
function List() {
  return (
    <Panel bodyClassName="p-0" className="w-full max-w-xs">
      <ScrollArea className="max-h-48 space-y-1 px-5 py-4 text-[13px]">
        {ROWS.map((row) => (
          <p key={row} className="py-1">
            {row}
          </p>
        ))}
      </ScrollArea>
    </Panel>
  )
}
// #endregion

// #region board
// Columns that each scroll under a panel's head (`over`: the head lies over what
// the panel holds), the head their cover. In glass the panel's spill ends them at
// its edge; plain, they fade.
function Board({ material }: { material: Material }) {
  return (
    <Panel
      material={material}
      over
      bodyClassName="grid grid-cols-2 gap-0 p-0"
      head={
        <div className="grid w-full grid-cols-2 font-mono text-xs">
          <span>planned</span>
          <span className="pl-5">building</span>
        </div>
      }
    >
      {[0, 1].map((column) => (
        <ScrollArea
          key={column}
          thumb
          fade={material === "plain"}
          cover="var(--panel-head)"
          className="max-h-96 px-5 pb-4 text-[13px]"
        >
          <ul className="space-y-2 pt-4">
            {ROWS.slice(column * 4).map((row) => (
              <li key={row} className="rounded-lg bg-current/8 px-3 py-2.5">
                {row}
              </li>
            ))}
          </ul>
        </ScrollArea>
      ))}
    </Panel>
  )
}
// #endregion

export default function ScrollAreaDemo() {
  const [material, setMaterial] = React.useState<Material>("glass")
  return (
    <div className="space-y-8">
      <Sample name="list" glance with="rows list" title="a short capped list">
        <List />
      </Sample>
      <Sample
        name="board"
        with="rows board"
        title="columns under a head"
        description="each column its own scroller, the head laid over their tops; plain they fade at their ends, in glass the panel's edge spills them."
      >
        <div className="space-y-4">
          <ToggleGroup
            size="sm"
            aria-label="material"
            value={material}
            onValueChange={(v) => setMaterial(v as Material)}
          >
            <ToggleGroupItem value="plain">plain</ToggleGroupItem>
            <ToggleGroupItem value="glass">glass</ToggleGroupItem>
          </ToggleGroup>
          <MaterialProvider material={material}>
            <div
              className={
                material === "glass"
                  ? "rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6"
                  : "p-6"
              }
            >
              <Board material={material} />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

scroll-area.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  NO_SCROLLBAR,
  ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import "./edge-fade"

// THE CAPPED SCROLLER, the one everything that scrolls inside the page scrolls with
// (a list in a popover, a kanban's column, a panel of a set height): give it a
// height cap and it scrolls inside itself, the browser's bar never shown. Its ends:
//
//   fade   (the default) what it holds fades itself out at an end with more to
//          show (scrims' `ag-edge-fade-y`, as deep as `CAPPED_FADE`), nothing
//          painted over it, so it sits on any surface, glass included.
//   none   `fade={false}`: the body of a glass panel, whose glass spills what nears
//          its edge round it (material-glass, `GlassSpill`), the glass ending it.
//          A fade there and a spill over it ended it twice.
//
// `thumb` adds overlay thumbs (scroll-thumb): a long scroller, where you need to know
// where in it you are, asks for them; a short one ends in its fade alone.
// `cover` and `coverEnd` are what is laid OVER its top and its foot (a panel's head
// and foot, never in its flow): what it holds starts below the one and ends above
// the other and passes beneath both, a focused row stops clear of them, the thumbs
// run between them, and a fade starts where the rows come out from under them. A
// front lies over it, never inside it: its fade is a mask, which makes it a backdrop
// root, and a glass front inside it would blur nothing.
// It takes its surface's corners (`rounded-[inherit]`): put it straight inside the
// surface and what it holds runs into the surface's own edge. No padding of its own:
// the caller's `className` holds it, with the height cap (a cover is its end's
// padding). At its top and foot the scroll goes on into the page (no
// `overscroll-*`).
//
//   <ScrollArea className="max-h-80 space-y-2 px-4 py-5">{rows}</ScrollArea>
//   <ScrollArea thumb cover="3rem" className="max-h-[40rem]">{long}</ScrollArea>
//
// A panel that scrolls (`Panel` `maxHeight`, `height`) is one already: reach for this
// inside a panel only for a part of it that scrolls on its own (a board's columns).

/** How deep a capped scroller's end fades: one length for every container that
 *  scrolls, so their ends read the same. */
export const CAPPED_FADE = "[--edge-fade:1rem]"

export interface ScrollAreaProps extends useRender.ComponentProps<"div"> {
  /** Overlay thumbs while it scrolls (scroll-thumb). Off: a short scroller ends in
   *  its fade alone. */
  thumb?: boolean
  /** Its ends fade out. Off only where a glass panel's spill ends it. Default on. */
  fade?: boolean
  /** The length of what is laid over its top (a head), a CSS length: what it
   *  holds starts below it and passes beneath it. */
  cover?: string
  /** The same for what is laid over its foot (a panel's foot). */
  coverEnd?: string
}

export function ScrollArea({
  thumb = false,
  fade = true,
  cover,
  coverEnd,
  render,
  className,
  style,
  ref,
  ...props
}: ScrollAreaProps) {
  const own = React.useRef<HTMLDivElement | null>(null)
  const both = React.useCallback(
    (node: HTMLDivElement | null) => {
      own.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref],
  )
  const covers = {
    ...(cover === undefined ? {} : { "--scroll-area-cover": cover }),
    ...(coverEnd === undefined ? {} : { "--scroll-area-cover-end": coverEnd }),
  }
  // `render`: the scroller is another element (Base UI's list in a select or a
  // combobox, a `<ul>`), which takes these props.
  const scroller = useRender({
    defaultTagName: "div",
    render,
    ref: both,
    props: {
      // Its slot unless the caller names it (a dialog's body, a panel's scroller).
      "data-slot": "scroll-area",
      ...props,
      className: cn(
        "relative min-h-0 rounded-[inherit]",
        fade
          ? ["ag-edge-fade-y", CAPPED_FADE]
          : ["overflow-y-auto", NO_SCROLLBAR],
        className,
        // After the caller's: a cover IS its end's padding, so pad what it holds
        // inside it (a panel's body) rather than the scroller.
        cover !== undefined && [
          "scroll-pt-(--scroll-area-cover) pt-(--scroll-area-cover)",
          fade && "[--edge-cover:var(--scroll-area-cover)]",
        ],
        coverEnd !== undefined && [
          "scroll-pb-(--scroll-area-cover-end) pb-(--scroll-area-cover-end)",
          fade && "[--edge-cover-end:var(--scroll-area-cover-end)]",
        ],
      ),
      style: { ...covers, ...style } as React.CSSProperties,
    },
  })
  if (!thumb) return scroller
  return (
    <>
      {scroller}
      {/* Beside the scroller, laid over it: inside, its fade would fade them too. */}
      <ScrollThumbs scroller={own} />
    </>
  )
}