the one capped scroller: its ends fade, it wears thumbs when long, the page scrolls on
npx shadcn add @ag/scroll-areaAlready installed? Preview an update
npx shadcn add @ag/scroll-area --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
- New:
ScrollArea, the one capped scroller, replacingscroll-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.thumbadds overlay thumbs, laid over it from beside it so its fade never fades them.coverandcoverEndare 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.rendermakes 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. Adata-slotgiven to it is kept. - Every capped vertical scroller in the registry is one now, so they end alike:
dialogandsheet's bodies (their slotsdialog-bodyandsheet-bodykept),select's,people-picker's andchip-input's lists,table's choices,icon-picker, thegallerytoolbar's settings,gallery-filters' sections,file-view's text,player's playlist,commandandcountry-picker. Each of these items now depends onscroll-area.icon-pickerhands the scroll on to the page at its ends, where it used to hold it.
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
"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
"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} />
</>
)
}