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