moduix

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.

Base UI API

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.

PropertyDefaultDescription
--scroll-area-bgtransparentControls the viewport background color.
--scroll-area-colorvar(--color-foreground)Controls the root text color.
--scroll-area-content-paddingvar(--spacing-3)Controls the content slot padding.
--scroll-area-corner-bgtransparentControls the corner color for two-axis scrolling.
--scroll-area-fade-end-sizevar(--scroll-area-fade-size)Controls vertical end fade.
--scroll-area-fade-inline-end-sizevar(--scroll-area-fade-size)Controls horizontal end fade.
--scroll-area-fade-inline-start-sizevar(--scroll-area-fade-size)Controls horizontal start fade.
--scroll-area-fade-sizevar(--spacing-10)Controls the default fade size.
--scroll-area-fade-start-sizevar(--scroll-area-fade-size)Controls vertical start fade.
--scroll-area-focus-ring-colorvar(--color-ring)Controls the viewport focus ring color.
--scroll-area-focus-ring-offset-1pxControls the viewport focus ring offset.
--scroll-area-focus-ring-widthvar(--border-width-sm)Controls the viewport focus ring width.
--scroll-area-height13remControls the root height.
--scroll-area-radiusvar(--radius-md)Controls the viewport border radius.
--scroll-area-scrollbar-bgtransparentControls the scrollbar track background color.
--scroll-area-scrollbar-hidden-opacity0Controls hidden scrollbar opacity.
--scroll-area-scrollbar-hit-area-size1.25remControls the invisible pointer hit area around the scrollbar.
--scroll-area-scrollbar-margincalc(var(--spacing-1) / 2)Controls spacing between scrollbar and viewport edge.
--scroll-area-scrollbar-padding0Controls scrollbar track padding.
--scroll-area-scrollbar-radiusvar(--radius-md)Controls scrollbar track radius.
--scroll-area-scrollbar-size0.375remControls the scrollbar track thickness.
--scroll-area-scrollbar-visible-opacity1Controls visible scrollbar opacity.
--scroll-area-thumb-bgvar(--color-border)Controls the draggable thumb color.
--scroll-area-thumb-min-size1.5remControls the minimum draggable thumb size.
--scroll-area-thumb-radiusvar(--radius-full)Controls the thumb border radius.
--scroll-area-transitionvar(--transition-default)Controls scrollbar fade timing.
--scroll-area-width24remControls the root width.

Interactive variables scoped for docs preview without changing size scale tokens.

PropertyValueDefaultDescription
--scroll-area-bgtransparentControls viewport background color.
--scroll-area-colorvar(--color-foreground)Controls root text color.
--scroll-area-content-paddingvar(--spacing-3)Controls content slot padding.
--scroll-area-focus-ring-colorvar(--color-ring)Controls viewport focus ring color.
--scroll-area-radiusvar(--radius-md)Controls viewport border radius.
--scroll-area-scrollbar-bgtransparentControls scrollbar track background color.
--scroll-area-thumb-bgvar(--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
PartRole
ScrollAreaRoot wrapper that controls overflow behavior and overall sizing.
viewportScroll container that receives pointer and keyboard scroll input.
contentInner content wrapper for your scrollable children.
verticalScrollbarVertical scrollbar track, shown based on overflow and settings.
horizontalScrollbarHorizontal scrollbar track, shown based on overflow and settings.
verticalThumbThumb inside the vertical scrollbar.
horizontalThumbThumb inside the horizontal scrollbar.
cornerCorner 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);

On this page