# ScrollArea (/docs/scroll-area)





## API Reference [#api-reference]

<BaseUIReference href="https://base-ui.com/react/components/scroll-area" />

## Basic [#basic]

<Preview cssProperties="scrollAreaPlaygroundCssProperties">
  <ScrollAreaExample />

  <Preview.Code>
    {`
          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>
            );
          }
        `}
  </Preview.Code>

  <Preview.CSS>
    {`
          .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);
          }
        `}
  </Preview.CSS>

  <Preview.Data>
    {`
          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.',
            },
          ];
        `}
  </Preview.Data>

  <Preview.CSSProperties>
    {(context) => <ScrollAreaCssPropertiesPanel {...context} />}
  </Preview.CSSProperties>

  <Preview.CSSPlayground>
    {(context) => <ScrollAreaCssPlaygroundPanel {...context} />}
  </Preview.CSSPlayground>
</Preview>

## Anatomy [#anatomy]

`ScrollArea` is composed around one visible root and internal scrolling infrastructure.
The component renders viewport, content, scrollbars, thumbs, and corner slots automatically.

```text
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 [#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:

```tsx
<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 [#examples]

### Both Scrollbars [#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.

<Preview>
  <BothScrollbarsScrollAreaExample />

  <Preview.Code>
    {`
          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>
            );
          }
        `}
  </Preview.Code>

  <Preview.CSS>
    {`
          .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);
          }
        `}
  </Preview.CSS>

  <Preview.Data>
    {`
          const cells = Array.from({ length: 96 }, (_, index) => index + 1);
        `}
  </Preview.Data>
</Preview>

### Gradient Fade [#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.

<Preview>
  <GradientFadeScrollAreaExample />

  <Preview.Code>
    {`
          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>
            );
          }
        `}
  </Preview.Code>

  <Preview.CSS>
    {`
          .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);
          }
        `}
  </Preview.CSS>

  <Preview.Data>
    {`
          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.',
            },
          ];
        `}
  </Preview.Data>
</Preview>

### Overflow Edge Threshold [#overflow-edge-threshold]

Use `overflowEdgeThreshold` when fade and edge attributes should appear only after the user scrolls
past a specific offset.

<Preview>
  <OverflowEdgeThresholdScrollAreaExample />

  <Preview.Code>
    {`
          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>
            );
          }
        `}
  </Preview.Code>

  <Preview.CSS>
    {`
          .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);
          }
        `}
  </Preview.CSS>

  <Preview.Data>
    {`
          const rows = Array.from(
            { length: 18 },
            (_, index) =>
              \`Threshold demo row \${index + 1}. Edge attributes appear only after the configured offset.\`,
          );
        `}
  </Preview.Data>
</Preview>

### Keep Mounted [#keep-mounted]

Use `scrollbarKeepMounted` when scrollbar DOM needs to stay mounted while the viewport changes between scrollable and non-scrollable states.

<Preview>
  <KeepMountedScrollAreaExample />

  <Preview.Code>
    {`
          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>
            );
          }
        `}
  </Preview.Code>

  <Preview.CSS>
    {`
          .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;
          }
        `}
  </Preview.CSS>

  <Preview.Data>
    {`
          const rows = ['KeepMounted demo row 1', 'KeepMounted demo row 2'];

          const denseRows = Array.from(
            { length: 16 },
            (_, index) => \`KeepMounted demo row \${index + 1}\`,
          );
        `}
  </Preview.Data>
</Preview>

### Custom Styles [#custom-styles]

Style every internal slot through `classNames`, including the viewport, content, both scrollbars,
both thumbs, and the corner.

<Preview>
  <CustomStylesScrollAreaExample />

  <Preview.Code>
    {`
          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>
            );
          }
        `}
  </Preview.Code>

  <Preview.CSS>
    {`
          .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);
          }
        `}
  </Preview.CSS>

  <Preview.Data>
    {`
          const cells = Array.from({ length: 80 }, (_, index) => index + 1);
        `}
  </Preview.Data>
</Preview>
