Skip to content

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.

Code
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>
  );
}
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

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.

Code
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>
  );
}
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.

Code
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>
  );
}
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 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.

<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>
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 props.

ScrollArea.Viewport

Accepts Base UI ScrollArea.Viewport props.

ScrollArea.Content

Accepts Base UI ScrollArea.Content props.

ScrollArea.Scrollbar

Accepts Base UI ScrollArea.Scrollbar props.

ScrollArea.Thumb

Accepts Base UI ScrollArea.Thumb props.

ScrollArea.StaticRoot

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

PropTypeDefaultDescription
data-elanorstring-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

PropTypeDefaultDescription
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 props.