ScrollArea
A native scroll container with automatically rendered custom scrollbars.
API Reference
Original primitive API
Behavior, accessibility details, and low-level props are documented by Base UI.
Basic
import { ScrollArea } from "moduix";export function ScrollAreaDemo() { return ( <ScrollArea className={styles.root} classNames={{ content: styles.textContent }}> {sections.map((item) => ( <section key={item.title}> <h3>{item.title}</h3> <p className={styles.paragraph}>{item.body}</p> </section> ))} </ScrollArea> );}.root { --scroll-area-width: 24rem; --scroll-area-height: 13rem; max-width: calc(100vw - 2rem); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-lg);}.textContent { display: grid; gap: var(--spacing-3);}.paragraph { margin: 0; color: var(--color-foreground); font-size: var(--text-sm); line-height: var(--line-height-text-sm);}const sections = [ { title: 'What this surface is for', body: 'Use temporary surfaces for focused tasks that should keep users in the current page context.', }, { title: 'Keyboard and focus', body: 'Tab and Shift+Tab should stay predictable while Escape or explicit controls request close.', }, { title: 'Viewport overflow', body: 'Keep the container visible and place long content in a dedicated scrollable inner region.', }, { title: 'Close affordances', body: 'Always provide an explicit close action when the surface can be dismissed by the user.', }, { title: 'Mobile ergonomics', body: 'Keep touch targets reachable and avoid cramped headers on narrow viewports.', }, { title: 'Persistent panels', body: 'For persistent workflows, keep the important controls fixed and scroll only the supporting content.', }, { title: 'Status updates', body: 'After completion, close the surface and show an inline confirmation or toast.', }, { title: 'Error handling', body: 'When an action fails, keep the user in context and show the recovery step near the failed control.', }, { title: 'Long descriptions', body: 'Dense explanatory copy should remain readable without pushing primary actions out of reach.', }, { title: 'Scrolling feedback', body: 'Visible scrollbars, fades, or edge states help users understand that additional content is available.', }, { title: 'Footer behavior', body: 'Footer actions should stay stable when the user reviews long terms, warnings, or settings.', }, { title: 'Review checklist', body: 'Use repeated sections to test keyboard scrolling, wheel scrolling, touch scrolling, and drag gestures.', }, { title: 'Final confirmation', body: 'The final section should be reachable without layout jumps or hidden content at the bottom edge.', },];Full list of component variables available for project-level overrides.
| Property | Default | Description |
|---|---|---|
| --scroll-area-bg | transparent | Controls the viewport background color. |
| --scroll-area-color | var(--color-foreground) | Controls the root text color. |
| --scroll-area-content-padding | var(--spacing-3) | Controls the content slot padding. |
| --scroll-area-corner-bg | transparent | Controls the corner color for two-axis scrolling. |
| --scroll-area-fade-end-size | var(--scroll-area-fade-size) | Controls vertical end fade. |
| --scroll-area-fade-inline-end-size | var(--scroll-area-fade-size) | Controls horizontal end fade. |
| --scroll-area-fade-inline-start-size | var(--scroll-area-fade-size) | Controls horizontal start fade. |
| --scroll-area-fade-size | var(--spacing-10) | Controls the default fade size. |
| --scroll-area-fade-start-size | var(--scroll-area-fade-size) | Controls vertical start fade. |
| --scroll-area-focus-ring-color | var(--color-ring) | Controls the viewport focus ring color. |
| --scroll-area-focus-ring-offset | -1px | Controls the viewport focus ring offset. |
| --scroll-area-focus-ring-width | var(--border-width-sm) | Controls the viewport focus ring width. |
| --scroll-area-height | 13rem | Controls the root height. |
| --scroll-area-radius | var(--radius-md) | Controls the viewport border radius. |
| --scroll-area-scrollbar-bg | transparent | Controls the scrollbar track background color. |
| --scroll-area-scrollbar-hidden-opacity | 0 | Controls hidden scrollbar opacity. |
| --scroll-area-scrollbar-hit-area-size | 1.25rem | Controls the invisible pointer hit area around the scrollbar. |
| --scroll-area-scrollbar-margin | calc(var(--spacing-1) / 2) | Controls spacing between scrollbar and viewport edge. |
| --scroll-area-scrollbar-padding | 0 | Controls scrollbar track padding. |
| --scroll-area-scrollbar-radius | var(--radius-md) | Controls scrollbar track radius. |
| --scroll-area-scrollbar-size | 0.375rem | Controls the scrollbar track thickness. |
| --scroll-area-scrollbar-visible-opacity | 1 | Controls visible scrollbar opacity. |
| --scroll-area-thumb-bg | var(--color-border) | Controls the draggable thumb color. |
| --scroll-area-thumb-min-size | 1.5rem | Controls the minimum draggable thumb size. |
| --scroll-area-thumb-radius | var(--radius-full) | Controls the thumb border radius. |
| --scroll-area-transition | var(--transition-default) | Controls scrollbar fade timing. |
| --scroll-area-width | 24rem | Controls the root width. |
Interactive variables scoped for docs preview without changing size scale tokens.
| Property | Value | Default | Description |
|---|---|---|---|
| --scroll-area-bg | transparent | Controls viewport background color. | |
| --scroll-area-color | var(--color-foreground) | Controls root text color. | |
| --scroll-area-content-padding | var(--spacing-3) | Controls content slot padding. | |
| --scroll-area-focus-ring-color | var(--color-ring) | Controls viewport focus ring color. | |
| --scroll-area-radius | var(--radius-md) | Controls viewport border radius. | |
| --scroll-area-scrollbar-bg | transparent | Controls scrollbar track background color. | |
| --scroll-area-thumb-bg | var(--color-border) | Controls draggable thumb color. |
Anatomy
ScrollArea is composed around one visible root and internal scrolling infrastructure.
The component renders viewport, content, scrollbars, thumbs, and corner slots automatically.
ScrollArea
├─ viewport
│ └─ content
├─ verticalScrollbar
│ └─ verticalThumb
├─ horizontalScrollbar
│ └─ horizontalThumb
└─ corner| Part | Role |
|---|---|
ScrollArea | Root wrapper that controls overflow behavior and overall sizing. |
viewport | Scroll container that receives pointer and keyboard scroll input. |
content | Inner content wrapper for your scrollable children. |
verticalScrollbar | Vertical scrollbar track, shown based on overflow and settings. |
horizontalScrollbar | Horizontal scrollbar track, shown based on overflow and settings. |
verticalThumb | Thumb inside the vertical scrollbar. |
horizontalThumb | Thumb inside the horizontal scrollbar. |
corner | Corner filler when both scrollbars are visible. |
Composition
Use ScrollArea for root behavior props such as scrollbars, fade, overflowEdgeThreshold,
and scrollbarKeepMounted. Use className for the root element and classNames for internal
slots hidden from the default composition.
Use contentMinWidth="fit-content" when horizontal overflow should follow intrinsic content width.
Shared classNames.scrollbar and classNames.thumb are applied before axis-specific classes, so
you can keep common styling in one class and override only vertical or horizontal details.
Use slotProps when you need Base UI escape hatches such as render, state-based style, or
other non-class props for an internal slot:
<ScrollArea
scrollbars="both"
className={styles.root}
classNames={{
viewport: styles.viewport,
content: styles.content,
scrollbar: styles.scrollbar,
verticalScrollbar: styles.verticalScrollbar,
horizontalScrollbar: styles.horizontalScrollbar,
thumb: styles.thumb,
verticalThumb: styles.verticalThumb,
horizontalThumb: styles.horizontalThumb,
corner: styles.corner,
}}
slotProps={{
viewport: { render: <section /> },
verticalScrollbar: { render: <aside /> },
}}
/>Examples
Both Scrollbars
Set scrollbars="both" when content can overflow in both axes. ScrollArea renders both
scrollbars and the corner automatically. Set contentMinWidth="fit-content" so the content keeps
its intrinsic width and can overflow horizontally; without it, the content usually collapses to the
viewport width and behaves like a vertical-only scroll area.
import { ScrollArea } from "moduix";export function BothScrollbarsScrollAreaDemo() { return ( <ScrollArea scrollbars="both" contentMinWidth="fit-content" className={styles.sizedRoot} classNames={{ content: styles.gridContent }} > {cells.map((cell) => ( <div key={cell} className={styles.cell}> {cell} </div> ))} </ScrollArea> );}.sizedRoot { --scroll-area-width: 24rem; --scroll-area-height: 13rem; max-width: calc(100vw - 2rem); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-lg);}.gridContent { display: grid; grid-template-columns: repeat(12, 5rem); grid-template-rows: repeat(8, 5rem); gap: var(--spacing-2); padding: var(--spacing-3);}.cell { display: grid; place-items: center; border-radius: var(--radius-sm); background-color: var(--color-muted); color: var(--color-foreground); font-size: var(--text-sm); font-weight: var(--weight-medium);}const cells = Array.from({ length: 96 }, (_, index) => index + 1);Gradient Fade
Set fade to fade content near the vertical scroll edges. Tune the size with
--scroll-area-fade-size, --scroll-area-fade-start-size, and --scroll-area-fade-end-size.
Pass fade="horizontal" for horizontal overflow or fade="both" when both axes can scroll.
import { ScrollArea } from "moduix";export function GradientFadeScrollAreaDemo() { return ( <ScrollArea fade className={styles.sizedRoot} classNames={{ content: styles.paddedTextContent }} > {sections.map((item) => ( <section key={item.title}> <h3>{item.title}</h3> <p className={styles.paragraph}>{item.body}</p> </section> ))} </ScrollArea> );}.sizedRoot { --scroll-area-width: 24rem; --scroll-area-height: 13rem; max-width: calc(100vw - 2rem); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-lg);}.paddedTextContent { display: grid; gap: var(--spacing-3); padding: var(--spacing-3); padding-right: var(--spacing-6);}.paragraph { margin: 0; color: var(--color-foreground); font-size: var(--text-sm); line-height: var(--line-height-text-sm);}const sections = [ { title: 'What this surface is for', body: 'Use temporary surfaces for focused tasks that should keep users in the current page context.', }, { title: 'Keyboard and focus', body: 'Tab and Shift+Tab should stay predictable while Escape or explicit controls request close.', }, { title: 'Viewport overflow', body: 'Keep the container visible and place long content in a dedicated scrollable inner region.', }, { title: 'Close affordances', body: 'Always provide an explicit close action when the surface can be dismissed by the user.', }, { title: 'Mobile ergonomics', body: 'Keep touch targets reachable and avoid cramped headers on narrow viewports.', }, { title: 'Persistent panels', body: 'For persistent workflows, keep the important controls fixed and scroll only the supporting content.', },];Overflow Edge Threshold
Use overflowEdgeThreshold when fade and edge attributes should appear only after the user scrolls
past a specific offset.
import { ScrollArea } from "moduix";export function OverflowEdgeThresholdScrollAreaDemo() { return ( <ScrollArea fade className={styles.sizedRoot} classNames={{ content: styles.compactTextContent }} overflowEdgeThreshold={28} > {rows.map((row) => ( <p key={row} className={styles.paragraph}> {row} </p> ))} </ScrollArea> );}.sizedRoot { --scroll-area-width: 24rem; --scroll-area-height: 13rem; max-width: calc(100vw - 2rem); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-lg);}.compactTextContent { display: grid; gap: var(--spacing-2); padding: var(--spacing-3); padding-right: var(--spacing-5);}.paragraph { margin: 0; color: var(--color-foreground); font-size: var(--text-sm); line-height: var(--line-height-text-sm);}const rows = Array.from( { length: 18 }, (_, index) => `Threshold demo row ${index + 1}. Edge attributes appear only after the configured offset.`,);Keep Mounted
Use scrollbarKeepMounted when scrollbar DOM needs to stay mounted while the viewport changes between scrollable and non-scrollable states.
import { ScrollArea } from "moduix";import { useState } from "react";export function KeepMountedScrollAreaDemo() { const [denseContent, setDenseContent] = useState(false); return ( <div className={styles.controls}> <button type="button" className={styles.button} onClick={() => setDenseContent((value) => !value)} > Toggle overflow: {denseContent ? "on" : "off"} </button> <ScrollArea className={styles.sizedRoot} classNames={{ content: styles.compactTextContent }} scrollbarKeepMounted > {(denseContent ? denseRows : rows).map((row) => ( <p key={row} className={styles.paragraph}> {row} </p> ))} </ScrollArea> </div> );}.sizedRoot { --scroll-area-width: 24rem; --scroll-area-height: 13rem; max-width: calc(100vw - 2rem); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-lg);}.compactTextContent { display: grid; gap: var(--spacing-2); padding: var(--spacing-3); padding-right: var(--spacing-5);}.paragraph { margin: 0; color: var(--color-foreground); font-size: var(--text-sm); line-height: var(--line-height-text-sm);}.controls { display: grid; gap: var(--spacing-3);}.button { width: fit-content; min-height: var(--size-sm); padding-inline: var(--spacing-3); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-background); color: var(--color-foreground); cursor: pointer;}const rows = ['KeepMounted demo row 1', 'KeepMounted demo row 2'];const denseRows = Array.from( { length: 16 }, (_, index) => `KeepMounted demo row ${index + 1}`,);Custom Styles
Style every internal slot through classNames, including the viewport, content, both scrollbars,
both thumbs, and the corner.
import { ScrollArea } from "moduix";export function CustomStylesScrollAreaDemo() { return ( <ScrollArea scrollbars="both" className={styles.customRoot} classNames={{ viewport: styles.customViewport, content: styles.customContent, verticalScrollbar: styles.customVerticalScrollbar, horizontalScrollbar: styles.customHorizontalScrollbar, verticalThumb: styles.customVerticalThumb, horizontalThumb: styles.customHorizontalThumb, corner: styles.customCorner, }} > {cells.map((cell) => ( <div key={cell} className={styles.customCell}> {cell} </div> ))} </ScrollArea> );}.customRoot { --scroll-area-width: 24rem; --scroll-area-height: 13rem; --scroll-area-scrollbar-size: 0.625rem; --scroll-area-scrollbar-margin: var(--spacing-1); --scroll-area-scrollbar-bg: color-mix(in srgb, var(--color-primary) 12%, transparent); --scroll-area-thumb-bg: var(--color-primary); --scroll-area-corner-bg: color-mix(in srgb, var(--color-primary) 12%, transparent); max-width: calc(100vw - 2rem); border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-lg);}.customViewport { border-radius: var(--radius-lg); outline: var(--border-width-sm) solid var(--color-border); outline-offset: calc(var(--border-width-sm) * -1); &[data-scrolling] { outline-color: var(--color-primary); }}.customContent { display: grid; grid-template-columns: repeat(10, 5rem); grid-template-rows: repeat(8, 4rem); gap: var(--spacing-2); padding: var(--spacing-3);}.customVerticalScrollbar { background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);}.customHorizontalScrollbar { background-color: color-mix(in srgb, var(--color-chart-3) 18%, transparent);}.customVerticalThumb { background-color: var(--color-primary);}.customHorizontalThumb { background-color: var(--color-chart-3);}.customCorner { border-radius: var(--radius-sm); background-color: color-mix(in srgb, var(--color-foreground) 10%, transparent);}.customCell { display: grid; place-items: center; border: var(--border-width-sm) solid var(--color-border); border-radius: var(--radius-sm); background-color: var(--color-background); color: var(--color-foreground); font-size: var(--text-sm); font-weight: var(--weight-medium);}const cells = Array.from({ length: 80 }, (_, index) => index + 1);