ScrollArea
Overview
A custom scroll container that keeps native scrolling behavior while rendering styled scrollbars for vertical and horizontal overflow.
Features
- Native scroll engine — scrolls with the browser’s native performance while the native scrollbar is hidden.
- Five visibility modes —
type="auto"(visible while overflow),"always"(always visible),"hover"(visible on hover),"scroll"(transiently visible while scrolling), and"glimpse"(a brief glimpse on hover). - Customizable hide delay —
scrollHideDelay(default 600ms) controls how long transient scrollbars (scroll/glimpse) stay visible. - Horizontal and vertical scrollbars — independent
ScrollAreaScrollbarcomponents per orientation with automatic overflow detection viaResizeObserver+ scroll metrics. - Draggable thumbs — pointer-drag the thumb to scroll; track clicks jump the viewport; dragging continues when the cursor leaves the thumb.
- Keyboard accessible viewport — the viewport is focusable (
tabindex="0") with a visible focus ring; arrow keys scroll natively. - RTL scroll normalization — detects the three browser RTL
scrollLeftmodes (default/negative/reverse) once per document (cached in aWeakMap) and normalizes them to a consistent 0 → max coordinate space for thumb math and drag behavior. - Full ARIA semantics — scrollbars and thumbs are
aria-hidden(custom scrollbars are purely decorative for AT); the root exposesdirfromuseDirection. - Composable structure —
ScrollAreaRoot/ScrollAreaViewport/ScrollAreaScrollbar/ScrollAreaThumb/ScrollAreaCornerare all exported from@vean/aria/scroll-areafor custom styled builds, plus theScrollAreaCompactaggregation. - Per-slot props forwarding —
viewportProps,verticalScrollbarProps,horizontalScrollbarProps,thumbProps,cornerPropson the compact component forward attributes to each region. - Corner rendering — the corner is rendered only when both scrollbars are visible, sized by the crossing scrollbar thicknesses.
- Size scaling —
size(xs…2xl) scales scrollbar thickness viascrollAreaVariants. - Performance — all state uses
shallowRef; derived values are cached incomputed; RTL mode detection is cached per document; scroll listeners use{ passive: true }; drag listeners are cleaned up onpointerup/pointercancel/ unmount.
Usage
Demos
Basic
Horizontal
Custom Styling
API
Aria Layer
7ScrollArea
Props
| Prop | Type | Default | Description |
|---|---|---|---|
class | ClassValue | — | Additional class names applied to the root element. |
ui | Partial<ScrollAreaUi> | — | Per-slot class overrides for the component. |
size | ThemeSize | — | Size of the ScrollArea component. |
viewportProps | ScrollAreaViewportProps | — | Properties forwarded to the viewport element. |
verticalScrollbarProps | ScrollAreaScrollbarProps | — | Properties forwarded to the vertical scrollbar element. |
horizontalScrollbarProps | ScrollAreaScrollbarProps | — | Properties forwarded to the horizontal scrollbar element. |
thumbProps | ScrollAreaThumbProps | — | Properties forwarded to the thumb elements. |
cornerProps | ScrollAreaCornerProps | — | Properties forwarded to the corner element. |
type | ScrollAreaType | — | Controls scrollbar visibility behavior. |
dir | Direction | — | The reading direction of the scroll area. |
scrollHideDelay | number | — | Delay before transient scrollbars are hidden. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
ScrollAreaCompact
Props
| Prop | Type | Default | Description |
|---|---|---|---|
viewportProps | ScrollAreaViewportProps | — | Properties forwarded to the viewport element. |
verticalScrollbarProps | ScrollAreaScrollbarProps | — | Properties forwarded to the vertical scrollbar element. |
horizontalScrollbarProps | ScrollAreaScrollbarProps | — | Properties forwarded to the horizontal scrollbar element. |
thumbProps | ScrollAreaThumbProps | — | Properties forwarded to the thumb elements. |
cornerProps | ScrollAreaCornerProps | — | Properties forwarded to the corner element. |
type | ScrollAreaType | — | Controls scrollbar visibility behavior. |
dir | Direction | — | The reading direction of the scroll area. |
scrollHideDelay | number | — | Delay before transient scrollbars are hidden. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
Slots
| Slot Name | Parameters | Description |
|---|---|---|
default | — | Custom content for the default slot. |
ScrollAreaCorner
Props
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
ScrollAreaRoot
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | ScrollAreaType | — | Controls scrollbar visibility behavior. |
dir | Direction | — | The reading direction of the scroll area. |
scrollHideDelay | number | — | Delay before transient scrollbars are hidden. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
ScrollAreaScrollbar
Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | ScrollAreaOrientation | — | The scrollbar orientation. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
ScrollAreaThumb
Props
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
ScrollAreaViewport
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nonce | string | — | Adds a nonce to the injected style tag. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
Notes
Architecture and benchmark comparison
| Concern | VeanUI | Radix UI ScrollArea | Ant Design ScrollBar |
|---|---|---|---|
| Aria / styled separation | ✅ @vean/aria/scroll-area ships logic; @vean/ui ships scv() recipe | ❌ single package (headless-style core only) | ❌ single styled package |
| Visibility modes | auto / always / hover / scroll / glimpse | auto / always / hover / scroll | auto / always / hover |
| Hide delay control | scrollHideDelay prop (default 600ms) | scrollHideDelay prop (default 600ms) | — |
RTL scrollLeft normalization | ✅ 3-mode detection (default / negative / reverse) + WeakMap cache | ✅ same approach | — |
| Thumb drag + track click | ✅ pointer drag + track jump | ✅ pointer drag + track jump | ✅ |
| Keyboard focusable viewport | ✅ tabindex="0" + focus ring | ✅ | — |
| Corner auto-render | ✅ rendered only when both scrollbars visible, sized by thickness | ✅ | — |
dir from ConfigProvider | ✅ useDirection fallback | ✅ dir prop | — |
| Region-level props forwarding | ✅ viewportProps / verticalScrollbarProps / horizontalScrollbarProps / thumbProps / cornerProps | ✅ asChild on each part | — |
| SSR safety | ✅ no window/document in setup | ✅ | — |
Runtime considerations
- Thumb math is proportion-based —
getThumbSizemapsviewportSize / contentSizeonto the track size, clamped to aMIN_THUMB_SIZEof 18px.getThumbOffsetclamps the scroll ratio to[0, 1]and inverts it for horizontal RTL. - Metrics update triggers — thumb size and offset are recalculated on viewport/content/scrollbar
ResizeObservercallbacks and on every viewportscrollevent (passive).onScrollbarSizeChangekeeps the root’s scrollbar size refs in sync for the corner sizing. - Drag continues off-thumb —
pointermove/pointerup/pointercancellisteners are attached towindow(not the thumb) so dragging works even when the cursor leaves the thumb. Listeners are removed onpointerup/pointercanceland on unmount. - RTL mode detection is one-time —
detectRtlScrollTypeprobes a temporary scroll container once perDocumentand caches the result in aWeakMap. The three modes (default/negative/reverse) are normalized so internal math always uses a 0 → max coordinate space. typeaffects visibility only, not behavior — overflow detection, drag, and scroll events work identically for all modes;typeonly decides when the scrollbars are painted (data-state="visible|hidden").- SSR safety — no
window/documentaccess in setup. The RTL probe andResizeObserveronly run client-side once elements exist.
FAQ
Which type should I use?
auto— scrollbars appear only while the content overflows.always— scrollbars are always painted when there is overflow.hover— scrollbars fade in on pointer hover (desktop-friendly).scroll— scrollbars appear transiently while scrolling, then hide afterscrollHideDelay.glimpse— a brief scrollbar glimpse on hover, useful for very clean minimal UIs.
Why are the scrollbars aria-hidden?
The custom scrollbars are purely visual; the viewport remains a native scroll container, so assistive technologies can rely on native scrolling semantics and the focusable viewport. Marking the decorations aria-hidden prevents redundant announcements.
How does RTL work?
Set dir="rtl" on the root (or rely on ConfigProvider). The component detects the browser’s RTL scrollLeft mode once per document and normalizes thumb position, drag delta, and track clicks so they behave identically in LTR and RTL.
Can I customize the scrollbar appearance?
Yes — pass ui (per-slot classes) to SScrollArea, or forward attributes via verticalScrollbarProps / horizontalScrollbarProps / thumbProps. For full control, build your own ScrollAreaRoot / ScrollAreaViewport / ScrollAreaScrollbar / ScrollAreaThumb composition from @vean/aria/scroll-area.
How do I make the viewport keyboard focusable?
It already is — the viewport sets tabindex="0" by default (override via viewportProps.tabindex) and shows a focus ring via focus-visible styles.
Why does the corner only appear sometimes?
The corner is rendered only when both horizontal and vertical scrollbars are visible simultaneously. Its size matches the crossing scrollbar thicknesses.
Does it work with SSR?
Yes — setup never touches window / document. The RTL probe and ResizeObserver only activate client-side once the viewport mounts.