Scroll area
Custom thin scrollbars over native scrolling. The browser still does the scrolling; Base UI overlays slim, neutral bars that follow a visibility policy, widen and darken as you reach for the thumb, linger briefly after you stop, sit on the correct inline edge under RTL, and re-derive inside a recessed surface. The viewport stays the keyboard-focusable, named region — the bars are pure paint.
Vertical
Bound the ScrollArea.Root with a height (and width) so its content overflows, wrap the content in a ScrollArea.Content, and render a vertical ScrollArea.Scrollbar. A bare scrollbar renders its own ScrollArea.Thumb, so there's nothing else to wire. Hover the bar and the thumb firms up and the gutter widens.
"use client";
import { ScrollArea } from "@stridge/noctis";
const CHANGELOG = [
"Sharpen the focus ring on every control.",
"Re-derive scrollbar colors under sunken scopes.",
"Mirror the scrollbar to the inline edge under RTL.",
"Fade the thumb in on hover and while scrolling.",
"Hold a minimum thumb length so it stays grabbable.",
"Respect reduced motion for the fade transition.",
"Bound the viewport so its content overflows.",
"Keep native scrolling — only the bars are custom.",
"Pin the corner where two bars meet.",
"Wrap content in an intrinsic-width container.",
];
export default function ScrollAreaVertical() {
return (
<ScrollArea.Root className="h-48 w-72 rounded-md border border-border bg-background">
<ScrollArea.Viewport aria-label="Changelog" className="p-4">
<ScrollArea.Content className="flex flex-col gap-2">
{CHANGELOG.map((line) => (
<p key={line} className="text-small text-muted">
{line}
</p>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
);
}
Both axes
Render a scrollbar per axis for a region that overflows in both directions, plus a ScrollArea.Corner for the square where the two bars meet. Each bar carries its own orientation, and the horizontal bar mirrors to the correct edge under RTL.
"use client";
import { ScrollArea } from "@stridge/noctis";
const ROWS = Array.from({ length: 12 }, (_, row) => row + 1);
const COLS = Array.from({ length: 10 }, (_, col) => col + 1);
export default function ScrollAreaBothAxes() {
return (
<ScrollArea.Root className="h-64 w-80 rounded-md border border-border bg-background">
<ScrollArea.Viewport aria-label="Grid" className="p-4">
<ScrollArea.Content>
<div className="grid w-max gap-2">
{ROWS.map((row) => (
<div key={row} className="flex gap-2">
{COLS.map((col) => (
<div
key={col}
className="flex size-16 shrink-0 items-center justify-center rounded-sm bg-control text-mini text-muted"
>
R{row}·C{col}
</div>
))}
</div>
))}
</div>
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
<ScrollArea.Scrollbar orientation="horizontal" />
<ScrollArea.Corner />
</ScrollArea.Root>
);
}
Visibility
type on ScrollArea.Root sets the visibility policy, matching the macOS overlay convention. hover (default) fades the bar in on hover or while scrolling; scroll shows it only while scrolling; auto shows it whenever the content overflows; always keeps it drawn as a persistent channel (with a faint gutter track). scrollHideDelay (default 600) is the rest-delay before the bar fades back out — the fade-in stays immediate, so the bar never flickers.
type="hover"Shows on area hover or while scrolling.type="scroll"Shows only while scrolling.type="auto"Shows whenever the content overflows.type="always"Stays drawn, with a gutter track."use client";
import { ScrollArea } from "@stridge/noctis";
const TYPES = ["hover", "scroll", "auto", "always"] as const;
const DESCRIPTIONS: Record<(typeof TYPES)[number], string> = {
hover: "Shows on area hover or while scrolling.",
scroll: "Shows only while scrolling.",
auto: "Shows whenever the content overflows.",
always: "Stays drawn, with a gutter track.",
};
const LINES = Array.from({ length: 9 }, (_, i) => `Item ${i + 1}`);
export default function ScrollAreaVisibilityModes() {
return (
<div className="flex flex-wrap justify-center gap-4">
{TYPES.map((type) => (
<div key={type} className="flex w-44 flex-col gap-2">
<div className="flex flex-col gap-0.5">
<code className="text-mini text-foreground">type="{type}"</code>
<span className="text-mini text-muted">{DESCRIPTIONS[type]}</span>
</div>
<ScrollArea.Root type={type} className="h-40 rounded-md border border-border bg-background">
<ScrollArea.Viewport aria-label={`${type} region`} className="p-3">
<ScrollArea.Content className="flex flex-col gap-1.5">
{LINES.map((line) => (
<p key={line} className="text-small text-muted">
{line}
</p>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
</div>
))}
</div>
);
}
Density
size scales the gutter — md by default, or a thinner sm for dense lists and data tables. A coarse (touch) pointer automatically fattens the bar and lengthens the thumb so it stays grabbable, regardless of size.
"use client";
import { ScrollArea } from "@stridge/noctis";
const ROWS = [
"app/layout.tsx",
"app/page.tsx",
"app/globals.css",
"components/button.tsx",
"components/dialog.tsx",
"components/scroll-area.tsx",
"lib/utils.ts",
"lib/tokens.ts",
"hooks/use-theme.ts",
"hooks/use-media.ts",
"styles/reset.css",
"package.json",
"tsconfig.json",
"vite.config.ts",
];
export default function ScrollAreaDensity() {
return (
<ScrollArea.Root size="sm" className="h-44 w-64 rounded-md border border-border bg-background">
<ScrollArea.Viewport aria-label="Files" className="p-1.5">
<ScrollArea.Content className="flex flex-col">
{ROWS.map((row) => (
<span key={row} className="rounded-sm px-2 py-1 font-mono text-mini text-muted">
{row}
</span>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
);
}
Edge fade
Set fade to mask the overflowing edges with a colourless scroll-shadow, signalling there's more content beyond the fold — the cue an auto-hiding overlay bar can't give while it's faded out. The mask reveals the background (no accent, no rounding), keys off the logical overflow edges so it mirrors under RTL, and is off by default so it never dims content inside a bordered surface.
"use client";
import { ScrollArea } from "@stridge/noctis";
const POSTS = [
"Polishing season: 228 interaction details, no new features.",
"Overlay scrollbars now linger after you stop scrolling.",
"The thumb firms up and widens when you reach for it.",
"Edge fades hint there's more content above and below.",
"Native scrolling stays — the bars are pure paint.",
"Reduced-motion users get the bar, minus the animation.",
"Right-to-left layouts mirror the fade automatically.",
'Dense lists can thin the gutter with size="sm".',
];
export default function ScrollAreaEdgeFade() {
return (
<ScrollArea.Root fade className="h-48 w-80 rounded-md bg-surface">
<ScrollArea.Viewport aria-label="Feed" className="px-4 py-5">
<ScrollArea.Content className="flex flex-col gap-4">
{POSTS.map((post) => (
<p key={post} className="text-small text-muted">
{post}
</p>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
);
}
Inside a recessed surface
Drop a scroll area inside a Surface with shade="sunken" (or any data-elevation="sunken" scope) and the thumb and track re-derive to that scope — the scrollbar reads as part of the well, not the canvas. Hover the thumb and it darkens against the recessed fill.
"use client";
import { ScrollArea, Surface } from "@stridge/noctis";
const ITEMS = [
"Inbox",
"Drafts",
"Sent",
"Archive",
"Spam",
"Trash",
"Starred",
"Snoozed",
"Important",
"Scheduled",
"All mail",
"Chats",
];
export default function ScrollAreaSunken() {
return (
<Surface shade="sunken" bordered className="h-48 w-64 rounded-lg">
<ScrollArea.Root className="h-full">
<ScrollArea.Viewport aria-label="Folders" className="p-2">
<ScrollArea.Content className="flex flex-col">
{ITEMS.map((item) => (
<span key={item} className="rounded-sm px-2 py-1.5 text-small text-muted">
{item}
</span>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
</Surface>
);
}
Accessibility
Native scrolling is preserved, so the overlay bar, thumb, and corner are decorative — they carry aria-hidden and never role="scrollbar" (that role is only for a fully custom JS scrollbar, and would duplicate the native scroll semantics). The viewport is the accessible region instead:
- It carries a role —
groupby default (a quiet, labelled boundary), orregionfor a page-level area that should appear in the landmarks rotor. - It needs an accessible name: pass
aria-labeloraria-labelledby. A development build warns if neither is set, because a focusable region with no name announces nothing. - Base UI keeps it conditionally focusable — in the tab order (and showing the focus ring) only while it overflows, so a region that fits its content is never an empty, silent focus stop.
The region below holds only non-focusable prose, so the viewport itself is what Tab reaches — then the arrow, Page, Home, and End keys scroll it.
"use client";
import { ScrollArea } from "@stridge/noctis";
// Plain prose with nothing focusable inside — so the only way a keyboard user reaches it is the
// viewport itself. Base UI keeps the viewport in the tab order while it overflows, so Tab lands on it
// (showing the focus ring) and the arrow / Page / Home / End keys scroll it natively.
const PARAGRAPHS = [
"Tab into this region — it has no links or buttons, so the scroll container itself takes focus and shows a ring.",
"Once focused, the arrow keys scroll it a line at a time, and they follow the writing direction under RTL.",
"Page Up and Page Down jump by a viewport; Home and End snap to the start and the end of the content.",
"Because the browser still does the scrolling, every native key and the OS smooth-scroll behaviour are preserved.",
"A region that does not overflow is left out of the tab order, so it never becomes an empty, silent focus stop.",
];
export default function ScrollAreaKeyboardRegion() {
return (
<ScrollArea.Root className="h-44 w-96 rounded-md border border-border bg-background">
<ScrollArea.Viewport aria-label="Keyboard scrolling notes" className="p-4">
<ScrollArea.Content className="flex flex-col gap-3">
{PARAGRAPHS.map((paragraph) => (
<p key={paragraph} className="text-small text-muted">
{paragraph}
</p>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
);
}
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus onto the viewport while it overflows and holds no focusable content, making the region reachable. |
| ↑ / ↓ | Scroll the focused viewport vertically by a line. |
| ← / → | Scroll the focused viewport horizontally; the arrows follow the writing direction under RTL. |
| Page Up / Page Down | Scroll the focused viewport by a page. |
| Home / End | Jump to the start or end of the scrollable content. |
| Space / Shift+Space | Page down / up. |
Scrolling is the browser's own — the custom bars only restyle it, so every native key and the OS smooth-scroll behaviour are preserved.
Anatomy
Compose a scroll area from its parts. ScrollArea.Root bounds the region, sets the visibility policy, and clips the overlaid bars to its corners; it renders no scroll behaviour of its own — native scrolling stays on the viewport.
ScrollArea.Root— the bounding box that overlays the scrollbars and carries the policy. Props:type(hover|scroll|auto|always),scrollHideDelay,size(sm|md),overscroll(contain|auto),fade, andtrack.ScrollArea.Viewport— the natively scrolled, accessible region; give it a bounded size so its content overflows, a role (groupby default, orregion), and an accessible name. Keyboard-focusable while it overflows, with a visible focus ring.ScrollArea.Content— the intrinsic-width wrapper, so horizontal overflow is measured from the content's natural width.ScrollArea.Scrollbar— one axis's decorative bar. Props:orientation(vertical|horizontal, defaultvertical) andkeepMounted(keep the bar in the DOM even without overflow; defaulted on fortype="always").ScrollArea.Thumb— the draggable handle; a bareScrollbarrenders one for you.ScrollArea.Corner— the square where a vertical and a horizontal bar meet.
Every rendered part carries a data-slot (noctis-scroll-area, noctis-scroll-area-viewport, noctis-scroll-area-content, noctis-scroll-area-scrollbar, noctis-scroll-area-thumb, noctis-scroll-area-corner) for host-side styling — pair it with the root's data-type/data-size/data-overscroll and the state attributes (data-orientation, data-hovering, data-scrolling, data-has-overflow-x, data-has-overflow-y, and the logical data-overflow-*-start/-end).
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every scroll area in that region retunes — e.g. .dense { --noctis-scroll-area-scrollbar-inline-size: 0.375rem; } thins every bar beneath it. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that forward to Base UI's Scroll Area list just the props they pass through. Expand a row for the full type and description.