RoadieRoadie
  • Home
  • Foundations
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeFoundationsComponents
Appearance

Components

  • Navigation

    • Breadcrumb
    • Navigator
    • Steps
    • Tabs
  • Layout

    • Pane
    • ScrollArea
    • Separator
  • Actions

    • Button
    • IconButton
    • Toggle
    • Toggle group
  • Forms

    • Forms overview
    • Autocomplete
    • Checkbox
    • Combobox
    • Field
    • Fieldset
    • Input
    • Label
    • Number field
    • OTP field
    • Radio group
    • Select
    • Slider
    • Switch
    • Textarea
  • Overlays

    • Dialog
    • Drawer
    • Menu
    • Popover
    • Toast
    • Tooltip
  • Collections

    • Accordion
    • Card
    • Carousel
    • Collapsible
    • DataTable
    • List
    • Marquee
    • Table
  • Status

    • Badge
    • Callout
    • Countdown
    • Empty state
    • Meter
    • Progress
    • Skeleton
    • Sparkline
    • StatTile
  • Media & brand

    • Avatar
    • Icon Tile
    • Image
    • Logo
    • QR code
    • SpotIllustration
  • Text

    • CalendarTile
    • Code
    • DateTime
    • Duration
    • Highlight
    • Mark
    • Prose

ScrollArea

A scroll container with a consistent custom scrollbar across platforms.

Import

import { ScrollArea } from '@oztix/roadie-components/scroll-area'

Examples

Default

The viewport is the scroll container. Give the root a height. It never sizes itself.

<ScrollArea className='h-48 rounded-xl border border-subtle'>
  <ScrollArea.Viewport className='p-4'>
    <div className='grid gap-3'>
      {Array.from({ length: 20 }, (_, i) => (
        <p key={i}>Row {i + 1}</p>
      ))}
    </div>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea>

Horizontal

Wrap the content in ScrollArea.Content so it measures at its natural width, and render a horizontal scrollbar.

<ScrollArea className='rounded-xl border border-subtle'>
  <ScrollArea.Viewport className='p-4'>
    <ScrollArea.Content>
      <div className='flex gap-3'>
        {Array.from({ length: 12 }, (_, i) => (
          <div
            key={i}
            className='grid size-24 shrink-0 place-content-center rounded-lg emphasis-subtle'
          >
            {i + 1}
          </div>
        ))}
      </div>
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar orientation='horizontal'>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea>

Both axes

Render both scrollbars plus a Corner so they don't overlap where they meet.

<ScrollArea className='h-48 rounded-xl border border-subtle'>
  <ScrollArea.Viewport className='p-4'>
    <ScrollArea.Content>
      <div className='grid w-[48rem] gap-3'>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>Row {i + 1} is wide enough to overflow both axes</p>
        ))}
      </div>
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
  <ScrollArea.Scrollbar orientation='horizontal'>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
  <ScrollArea.Corner />
</ScrollArea>

Kept-mounted scrollbar

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.

<ScrollArea className='h-48 rounded-xl border border-subtle'>
  <ScrollArea.Viewport className='p-4'>
    <p>Short content. The bar stays mounted but hidden.</p>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar keepMounted>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea>

Edge fade

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.

<ScrollArea className='h-48 rounded-xl border border-subtle'>
  <ScrollArea.Viewport fade='y' className='px-4'>
    <div className='grid gap-3 py-4'>
      {Array.from({ length: 20 }, (_, i) => (
        <p key={i}>Row {i + 1}</p>
      ))}
    </div>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea>

Use fade='x' on a horizontal area, or fade='both' when either axis can overflow.

<ScrollArea className='rounded-xl border border-subtle'>
  <ScrollArea.Viewport fade='x' className='py-4'>
    <ScrollArea.Content>
      <div className='flex gap-3 px-4'>
        {Array.from({ length: 12 }, (_, i) => (
          <div
            key={i}
            className='grid size-24 shrink-0 place-content-center rounded-lg emphasis-subtle'
          >
            {i + 1}
          </div>
        ))}
      </div>
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar orientation='horizontal'>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea>

With a header

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.

<div className='grid h-48 grid-rows-[auto_1fr] overflow-hidden rounded-xl border border-subtle'>
  <header className='border-b border-subtle px-4 py-2 text-strong'>
    Header
  </header>
  <ScrollArea>
    <ScrollArea.Viewport className='p-4'>
      <div className='grid gap-3'>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>Row {i + 1}</p>
        ))}
      </div>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar>
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
  </ScrollArea>
</div>

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.

Guidelines

Constrain the height

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.

Read scroll state from the viewport

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.

Clamp an axis with style, not a class

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.

Keep chrome outside the scroll area

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.

Wrap content that can change

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.

  • Scrollbars sit inset by default. The bar is pinned to the root's edges, so the inset is what keeps its ends clear of a rounded corner. On a square, untinted area there is no corner to clear. Pass flush and let it sit against the edge.
  • Repositioning the root also needs 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' }}.
  • The scrollbar hides on touch. Coarse pointers get the platform's own transient overlay bar instead. If an area's only scroll hint is the bar, add fade so touch users get one too.
  • Don't combine fade with pinned content. The mask applies to everything the viewport paints, so anything held at an edge fades along with it.
  • Don't wrap the page. Roadie's app shell gives scroll to panes and other bounded regions; a document-level custom scrollbar loses the browser's built-in scroll affordances.

Accessibility

  • The viewport is a real scroll container, so keyboard scrolling (arrows, Page Up/Down, Home/End, Space) works natively once it is focused. Base UI makes it focusable when it overflows.
  • The scrollbar and thumb are pointer affordances only. Nothing keyboard-only or screen-reader-only depends on them.
  • Roadie hides the bar at rest and reveals it on hover or while scrolling; the fade is wrapped in 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.

API reference

ScrollArea

overflowEdgeThresholdnumber | Partial<{ xStart: number; xEnd: number; yStart: number; yEnd: number; }>

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.

classNamestring | ((state: ScrollAreaRootState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

ScrollArea.Content

classNamestring | ((state: ScrollAreaContentState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

fitWidthboolean

Size to the content's natural width; turn off to follow the viewport's.

Defaults to true.

ScrollArea.Corner

No additional props. It forwards all standard HTML attributes to the underlying element.

ScrollArea.Scrollbar

classNamestring | ((state: ScrollAreaScrollbarState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

flushboolean

Sit against the edge instead of inset to clear a rounded corner.

Defaults to false.

Inherited from ScrollAreaScrollbarProps

orientation"horizontal" | "vertical"

Whether the scrollbar controls vertical or horizontal scroll.

Defaults to vertical.

keepMountedboolean

Whether to keep the HTML element in the DOM when the viewport isn't scrollable.

Defaults to false.

ScrollArea.Thumb

No additional props. It forwards all standard HTML attributes to the underlying element.

ScrollArea.Viewport

classNamestring | ((state: ScrollAreaViewportState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

fade"none" | "both" | "y" | "x"

Fade edges with more to scroll; sticky content fades too.

Defaults to 'none'.

Previous page← PaneNext pageSeparator →