A scroll container with a consistent custom scrollbar across platforms.
import { ScrollArea } from '@oztix/roadie-components/scroll-area'
The viewport is the scroll container. Give the root a height. It never sizes itself.
Wrap the content in ScrollArea.Content so it measures at its natural width,
and render a horizontal scrollbar.
Render both scrollbars plus a Corner so they don't overlap where they meet.
Base UI unmounts the scrollbar entirely when its axis has no overflow.
keepMounted opts out of that, so the bar stays in the DOM for anything that
needs to bind to it before content overflows. It is not an always-visible bar:
it stays transparent, even on hover, until its axis overflows.
fade softens the content at any edge with more to scroll, and clears once you
reach that end. Override --scroll-area-fade-size to retune the depth.
Use fade='x' on a horizontal area, or fade='both' when either axis can
overflow.
Base UI pins the scrollbar to the root, spanning its full height. Keep
chrome outside the ScrollArea so the bar stays within the content it scrolls.
A grid-rows-[auto_1fr] wrapper gives the header its own row and hands the
rest to the scroll area.
A position: sticky child inside the viewport also pins, but the scrollbar
runs over it unless you raise the child above it. Raising it hides the top of
the bar's travel. Prefer the composition above.
ScrollArea never sizes itself. Without a bounded height the content just grows and nothing scrolls.
<ScrollArea className='h-48'>
Do
Give the root a height, or a parent that bounds it. Inside a grid or flex
parent it also needs min-h-0, which the root already sets.
<ScrollArea>
Don’t
Leave the height open and expect a scrollbar. The viewport grows to fit, so there is never any overflow to scroll.
The root is only the positioning context. The viewport is the element that actually scrolls.
const el = root.querySelector('[data-slot="scroll-area-viewport"]')el.scrollTo({ top: 0 })
Do
Target the viewport for scrollTop, scrollTo and the scroll event.
root.scrollTo({ top: 0 })
Don’t
Reach for the root. It never scrolls, so the call silently does nothing.
Base UI sets overflow: scroll as an inline style on the viewport, and an inline style beats any class.
<ScrollArea.Viewport style={{ overflowX: 'clip' }}>
Do
Pass the clamp through style, where it merges with Base UI's own and
wins on that axis.
<ScrollArea.Viewport className='overflow-x-hidden'>
Don’t
Use a Tailwind overflow-* class. It loses to the inline style and the
content still drags sideways.
The scrollbar is pinned to the root and spans its full height.
<div className='grid grid-rows-[auto_1fr]'><header>Header</header><ScrollArea>…</ScrollArea></div>
Do
Give the header its own row so the bar stays beside the content it scrolls.
<ScrollArea.Viewport><header className='sticky top-0'>Header</header>…</ScrollArea.Viewport>
Don’t
Pin a header inside the viewport. The bar runs over it, and raising the header instead hides the top of the bar's travel.
Base UI watches ScrollArea.Content with a ResizeObserver. The viewport is only observed for its own box, which never changes. Without the wrapper, an area whose content grows or shrinks keeps whatever overflow state it started with.
<ScrollArea.Viewport><ScrollArea.Content fitWidth={false}>{items}</ScrollArea.Content></ScrollArea.Viewport>
Do
Wrap content that changes height. fitWidth={false} keeps the wrapper at
the viewport's width. Drop it when the area scrolls sideways and should
measure at its natural width.
<ScrollArea.Viewport>{items}</ScrollArea.Viewport>
Don’t
Put changing content straight in the viewport. The scrollbar sticks around after the content shrinks, sized for whatever was longest.
flush and let it sit
against the edge.style. Base UI sets
position: relative inline on the root, so an absolute or fixed class
loses to it. To float the whole area, such as a popover or a dropdown pane,
pass style={{ position: 'absolute' }}.fade so touch users get one too.fade with pinned content. The mask applies to everything
the viewport paints, so anything held at an edge fades along with it.motion-safe, so prefers-reduced-motion gets an instant
swap. On coarse pointers the bar is hidden entirely and the platform's own
overlay bar takes over.fade is decoration only. It masks paint, never content, so nothing is
removed from the accessibility tree or from find-in-page.The threshold in pixels that must be passed before the overflow edge attributes are applied. Accepts a single number for all edges or an object to configure them individually.
Defaults to 0.
CSS class applied to the element, or a function that returns a class based on the component's state.
CSS class applied to the element, or a function that returns a class based on the component's state.
Size to the content's natural width; turn off to follow the viewport's.
Defaults to true.
No additional props. It forwards all standard HTML attributes to the underlying element.
CSS class applied to the element, or a function that returns a class based on the component's state.
Sit against the edge instead of inset to clear a rounded corner.
Defaults to false.
Inherited from ScrollAreaScrollbarProps
Whether the scrollbar controls vertical or horizontal scroll.
Defaults to vertical.
Whether to keep the HTML element in the DOM when the viewport isn't scrollable.
Defaults to false.
No additional props. It forwards all standard HTML attributes to the underlying element.
CSS class applied to the element, or a function that returns a class based on the component's state.
Fade edges with more to scroll; sticky content fades too.
Defaults to 'none'.