# Scroll area

A bounded region with native scrolling and custom scrollbars, such as a long list inside a panel. Not for page scrolling or virtualized lists.

```tsx
// demo.tsx
import {ScrollArea, Text} from '@earendil-works/elanor-react';

import styles from './scroll-area.module.css';

export default function ScrollAreaDemo() {
  return (
    <ScrollArea.Root className={styles.frame}>
      <ScrollArea.Viewport role="region" aria-label="Release notes">
        <ScrollArea.Content className={styles.entries}>
          {Array.from({length: 12}, (_, index) => (
            <Text key={index}>Release {index + 1}: navigation and layout improvements.</Text>
          ))}
        </ScrollArea.Content>
      </ScrollArea.Viewport>
      <ScrollArea.Scrollbar>
        <ScrollArea.Thumb />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>
  );
}
```

```css
/* scroll-area.module.css */
.frame {
  block-size: calc(var(--semantic-dimension-size-x-large) * 4);
  max-inline-size: var(--semantic-dimension-size-max-width-content-small);
  border: var(--semantic-dimension-border-width-default) solid
    var(--semantic-color-border-secondary);
  border-radius: var(--semantic-dimension-radius-small);
}

.entries {
  display: flex;
  flex-direction: column;
  gap: var(--semantic-dimension-space-gap-medium);
}

.wide {
  /* stylelint-disable-next-line design-tokens/no-hardcoded-token-values -- Demo frame size: content wider than the frame, so it scrolls. */
  inline-size: 76rem;
}
```

## Usage

```tsx
import {ScrollArea} from '@earendil-works/elanor-react';

<ScrollArea.Root>
  <ScrollArea.Viewport role="region" aria-label="Release notes">
    <ScrollArea.Content />
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea.Root>;
```

- **Root** needs a bounded height, set directly or by a flex or grid parent.
- **Viewport** is the element that scrolls. Name it, and put `ref`, `onScroll`, and scroll calls here. It's focusable only while content overflows, so keyboard users can scroll it.
- **Content** has padding by default, so scrollbars don't cover text or focus outlines. Add `style={{minWidth: 0}}` when content should shrink or truncate instead of growing wider.
- **Scrollbar** appears only when content overflows.

For right-to-left layouts, set `dir` on the element and `DirectionProvider` to the same direction.

## Both axes

Add a horizontal Scrollbar and a Corner where the two scrollbars meet.

```tsx
// both-axes.tsx
import {ScrollArea, Text} from '@earendil-works/elanor-react';

import styles from './scroll-area.module.css';

export default function ScrollAreaBothAxes() {
  return (
    <ScrollArea.Root className={styles.frame}>
      <ScrollArea.Viewport role="region" aria-label="Deployment log">
        <ScrollArea.Content className={`${styles.entries} ${styles.wide}`}>
          {Array.from({length: 8}, (_, index) => (
            <Text key={index}>
              Step {index + 1}: building, testing, and uploading assets to every region.
            </Text>
          ))}
        </ScrollArea.Content>
      </ScrollArea.Viewport>
      <ScrollArea.Scrollbar>
        <ScrollArea.Thumb />
      </ScrollArea.Scrollbar>
      <ScrollArea.Scrollbar orientation="horizontal">
        <ScrollArea.Thumb />
      </ScrollArea.Scrollbar>
      <ScrollArea.Corner />
    </ScrollArea.Root>
  );
}
```

```css
/* scroll-area.module.css */
.frame {
  block-size: calc(var(--semantic-dimension-size-x-large) * 4);
  max-inline-size: var(--semantic-dimension-size-max-width-content-small);
  border: var(--semantic-dimension-border-width-default) solid
    var(--semantic-color-border-secondary);
  border-radius: var(--semantic-dimension-radius-small);
}

.entries {
  display: flex;
  flex-direction: column;
  gap: var(--semantic-dimension-space-gap-medium);
}

.wide {
  /* stylelint-disable-next-line design-tokens/no-hardcoded-token-values -- Demo frame size: content wider than the frame, so it scrolls. */
  inline-size: 76rem;
}
```

## Scrolling from code

Call scroll methods on the Viewport.

```tsx
// scroll-from-code.tsx
import {Button, ScrollArea, Stack, Text} from '@earendil-works/elanor-react';
import {useRef} from 'react';

import styles from './scroll-area.module.css';

export default function ScrollAreaScrollFromCode() {
  const viewportRef = useRef<HTMLDivElement>(null);

  return (
    <Stack gap="medium" align="stretch">
      <ScrollArea.Root className={styles.frame}>
        <ScrollArea.Viewport ref={viewportRef} role="region" aria-label="Release notes">
          <ScrollArea.Content className={styles.entries}>
            {Array.from({length: 12}, (_, index) => (
              <Text key={index}>Release {index + 1}: navigation and layout improvements.</Text>
            ))}
          </ScrollArea.Content>
        </ScrollArea.Viewport>
        <ScrollArea.Scrollbar>
          <ScrollArea.Thumb />
        </ScrollArea.Scrollbar>
      </ScrollArea.Root>
      <Stack direction="row" gap="small">
        <Button hierarchy="secondary" onClick={() => viewportRef.current?.scrollTo({top: 0})}>
          Scroll to start
        </Button>
        <Button
          hierarchy="secondary"
          onClick={() => {
            const viewport = viewportRef.current;
            viewport?.scrollTo({top: viewport.scrollHeight});
          }}
        >
          Scroll to end
        </Button>
      </Stack>
    </Stack>
  );
}
```

```css
/* scroll-area.module.css */
.frame {
  block-size: calc(var(--semantic-dimension-size-x-large) * 4);
  max-inline-size: var(--semantic-dimension-size-max-width-content-small);
  border: var(--semantic-dimension-border-width-default) solid
    var(--semantic-color-border-secondary);
  border-radius: var(--semantic-dimension-radius-small);
}

.entries {
  display: flex;
  flex-direction: column;
  gap: var(--semantic-dimension-space-gap-medium);
}

.wide {
  /* stylelint-disable-next-line design-tokens/no-hardcoded-token-values -- Demo frame size: content wider than the frame, so it scrolls. */
  inline-size: 76rem;
}
```

## Styling states

The thumb is `semantic.color.background.fill.primary.neutral` in every state, including while hovered and scrolling. Style the parts with Base UI state attributes:

| Attribute                                      | Meaning                                  |
| ---------------------------------------------- | ---------------------------------------- |
| `data-has-overflow-x`, `data-has-overflow-y`   | Content is larger than the viewport      |
| `data-overflow-y-start`, `data-overflow-y-end` | More content above or below              |
| `data-overflow-x-start`, `data-overflow-x-end` | More content at an inline edge           |
| `data-scrolling`                               | Scrolling is in progress                 |
| `data-hovering`                                | The pointer is over the area (Scrollbar) |
| `data-orientation`                             | Scrollbar and Thumb axis                 |

Don't wrap an element that already has `overflow: auto`. [Sidebar](/components/sidebar.md) uses its own scrollbar visibility and overflow cues.

## Static rendering

On pages rendered on the server without React in the browser, use the static parts, and bind their behavior once the page has loaded. For both axes, add a horizontal `StaticScrollbar` and a `StaticCorner`.

```tsx
<ScrollArea.StaticRoot>
  <ScrollArea.StaticViewport role="region" aria-label="Release notes">
    <ScrollArea.StaticContent />
  </ScrollArea.StaticViewport>
  <ScrollArea.StaticScrollbar>
    <ScrollArea.StaticThumb />
  </ScrollArea.StaticScrollbar>
  <ScrollArea.StaticScrollbar orientation="horizontal">
    <ScrollArea.StaticThumb />
  </ScrollArea.StaticScrollbar>
  <ScrollArea.StaticCorner />
</ScrollArea.StaticRoot>
```

```ts
import {createScrollArea, enhance} from '@earendil-works/elanor-react';

enhance({'scroll-area': createScrollArea});
```

The behavior hides the native scrollbar and draws the custom one with the same states as the React parts. Without it, the viewport keeps its native scrollbar.

## API

### `ScrollArea.Root`

Accepts Base UI [`ScrollArea.Root`](https://base-ui.com/react/components/scroll-area.md) props.

### `ScrollArea.Viewport`

Accepts Base UI [`ScrollArea.Viewport`](https://base-ui.com/react/components/scroll-area.md) props.

### `ScrollArea.Content`

Accepts Base UI [`ScrollArea.Content`](https://base-ui.com/react/components/scroll-area.md) props.

### `ScrollArea.Scrollbar`

Accepts Base UI [`ScrollArea.Scrollbar`](https://base-ui.com/react/components/scroll-area.md) props.

### `ScrollArea.Thumb`

Accepts Base UI [`ScrollArea.Thumb`](https://base-ui.com/react/components/scroll-area.md) props.

### `ScrollArea.StaticRoot`

A scroll area for pages without React in the browser; `createScrollArea` makes it work.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data-elanor` | `string` | - | The page's own behaviors for this element, attached after the component's. |

Renders `<div>`. Also accepts its HTML attributes and `ref`.

### `ScrollArea.StaticViewport`

Renders `<div>`. Accepts its HTML attributes and `ref`.

### `ScrollArea.StaticContent`

Renders `<div>`. Accepts its HTML attributes and `ref`.

### `ScrollArea.StaticScrollbar`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `'horizontal' \| 'vertical'` | `'vertical'` |  |

Renders `<div>`. Also accepts its HTML attributes and `ref`.

### `ScrollArea.StaticThumb`

Renders `<div>`. Accepts its HTML attributes and `ref`.

### `ScrollArea.StaticCorner`

Where the two scrollbars meet, shown while both are.

Renders `<div>`. Accepts its HTML attributes and `ref`.

### `ScrollArea.Corner`

Accepts Base UI [`ScrollArea.Corner`](https://base-ui.com/react/components/scroll-area.md) props.
