Stems UI
Menu

Scroll Area

A native scroll container with custom scrollbars.

Codeapp/demos/public/scroll-area.tsx
import { clientEntry, Fragment, type Handle } from 'remix/component'
import { ScrollArea, ScrollBar } from '@stemsui/registry/ui/scroll-area.tsx'
import { Separator } from '@stemsui/registry/ui/separator.tsx'

const tags = Array.from({ length: 50 }).map((_, i, a) => `v1.2.0-beta.${a.length - i}`)

const works = [
  {
    artist: 'Ornella Binni',
    art: 'https://images.unsplash.com/photo-1465869185982-5a1a7522cbcb?auto=format&fit=crop&w=300&q=80',
  },
  {
    artist: 'Tom Byrom',
    art: 'https://images.unsplash.com/photo-1548516173-3cabfa4607e9?auto=format&fit=crop&w=300&q=80',
  },
  {
    artist: 'Vladimir Malyavko',
    art: 'https://images.unsplash.com/photo-1494337480532-3725c85fd2ab?auto=format&fit=crop&w=300&q=80',
  },
]

export const ScrollAreaDemo = clientEntry(
  import.meta.url,
  function ScrollAreaDemo(_handle: Handle) {
    return () => (
      <div class="flex flex-col items-center gap-8">
        <ScrollArea class="h-72 w-48 rounded-md border">
          <div class="p-4">
            <h4 class="mb-4 text-sm leading-none font-medium">Tags</h4>
            {tags.map((tag) => (
              <Fragment key={tag}>
                <div class="text-sm">{tag}</div>
                <Separator class="my-2" />
              </Fragment>
            ))}
          </div>
        </ScrollArea>

        <ScrollArea class="w-96 max-w-full rounded-md border whitespace-nowrap">
          <div class="flex w-max space-x-4 p-4">
            {works.map((artwork) => (
              <figure key={artwork.artist} class="shrink-0">
                <div class="overflow-hidden rounded-md">
                  <img
                    src={artwork.art}
                    alt={`Photo by ${artwork.artist}`}
                    class="aspect-[3/4] h-fit w-fit object-cover"
                    width={300}
                    height={400}
                  />
                </div>
                <figcaption class="pt-2 text-xs text-muted-foreground">
                  Photo by <span class="font-semibold text-foreground">{artwork.artist}</span>
                </figcaption>
              </figure>
            ))}
          </div>
          <ScrollBar orientation="horizontal" />
        </ScrollArea>
      </div>
    )
  },
)

Installation

npx shadcn add @stemsui/scroll-area

Requires the one-time setup. Behavior: stems-ui/scroll-area.

API reference

The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.

stems-ui/scroll-area

scroll-area is a native scroll container with custom scrollbars. The viewport scrolls natively (wheel, touch, keyboard, find-in-page, scroll anchoring all keep working); the native scrollbars are hidden and replaced by styleable tracks and thumbs that stay in sync.

Base UI equivalent: Scroll Area. Behavior is adapted from Base UI 1.9.0 (MIT).

Usage

import * as scrollArea from 'stems-ui/scroll-area'

function Tags() {
  return () => (
    <scrollArea.Context>
      <div class="scroll-area" mix={scrollArea.root()}>
        <div class="viewport" mix={scrollArea.viewport()}>
          <div mix={scrollArea.content()}>{/* long content */}</div>
        </div>
        <div class="scrollbar" mix={scrollArea.scrollbar()}>
          <div class="thumb" mix={scrollArea.thumb()} />
        </div>
        <div class="scrollbar" mix={scrollArea.scrollbar({ orientation: 'horizontal' })}>
          <div class="thumb" mix={scrollArea.thumb({ orientation: 'horizontal' })} />
        </div>
        <div mix={scrollArea.corner()} />
      </div>
    </scrollArea.Context>
  )
}

Size the root and give the viewport height: 100% (or a fixed size); give vertical scrollbars a width and horizontal ones a height.

stems-ui/scroll-area

  • <scrollArea.Context>: owns the measured state.
    • overflowEdgeThreshold: pixels that must be scrolled past an edge before its data-overflow-* attribute applies. A number for every edge or { xStart, xEnd, yStart, yEnd }. Defaults to 0.
    • Context value (handle.context.get(scrollArea.Context)): state (a live, read-only ScrollAreaState: hovering, scrolling, scrollingX, scrollingY, hasOverflowX, hasOverflowY, overflowXStart, overflowXEnd, overflowYStart, overflowYEnd, cornerHidden) and measure(), which re-measures after changes no observer can see.
  • scrollArea.root(): groups the parts. role="presentation", position: relative (host style can override), tracks hover.
  • scrollArea.viewport(): the scroll container. overflow: scroll and scrollbar-width: none (host style can override). Focusable (tabindex="0") while it can scroll, -1 otherwise, unless the host sets tabIndex.
  • scrollArea.content(): optional wrapper inside the viewport. role="presentation", min-width: fit-content. Its size is observed, so content growth updates the scrollbars. Without it only viewport resizes are observed (call measure() yourself).
  • scrollArea.scrollbar(options?)
    • orientation: 'vertical' (default) or 'horizontal'.
    • keepMounted: keep the scrollbar visible when its axis doesn't overflow. Defaults to false.
  • scrollArea.thumb(options?)
    • orientation: the orientation of its scrollbar. Without it the thumb reads it from its scrollbar after mounting, so pass it on horizontal thumbs for correct server-rendered markup.
  • scrollArea.corner(): fills the corner where both scrollbars meet.
  • Types: ScrollAreaContextProps, ScrollAreaContextValue, ScrollAreaState, ScrollAreaScrollbarOptions, ScrollAreaThumbOptions, ScrollAreaOrientation, OverflowEdgeThreshold, OverflowEdges.

There are no change events: Base UI's scroll area has no change callbacks. Listen to the viewport's native scroll event if you need one.

Behavior notes

  • Scrolling stays native. Keyboard scrolling (arrows, Page Up/Down, Home/End, Space) works when the viewport has focus; it is in the tab order only while it can scroll, as in Base UI.
  • Thumbs are dragged with pointer capture. Dragging disables CSS scroll snapping until release (like native scrollbars). A move without the primary button ends a drag whose release got lost.
  • Pressing the track jumps so the thumb centers on the pointer, then keeps dragging from there (Base UI's behavior; it does not page).
  • Wheel over a scrollbar scrolls the viewport (the scrollbar sits outside it), and chains to the page at an edge.
  • data-scrolling is set for user scrolls only (wheel, touch, keyboard, pointer, thumb drags), not for programmatic scrollTop changes, and clears 500 ms after the last scroll.
  • data-hovering is set on scrollbars while a mouse or pen is over the root (not touch).
  • RTL: the horizontal thumb, overflow edges, and track presses follow getDirection(viewport) (nearest dir, <DirectionProvider>, or computed direction). Scrollbars and the corner use logical inset-inline-* positions, so they flip with the layout.
  • No overflow: scrollbars get hidden (Base UI doesn't render them; a mixin can't remove its host). The corner is hidden unless both axes overflow. hidden needs a stylesheet that doesn't override it (Tailwind's preflight makes [hidden] display: none !important).
  • Measuring runs after hydration and whenever the viewport or content() resizes, or the viewport scrolls. Scroll updates write attributes, CSS variables and the thumb transform straight to the DOM instead of re-rendering, so scrolling never re-renders your content. Mixin renders read the same state, so app re-renders agree with the DOM.
  • Server rendering: nothing is measured. Scrollbars and the corner render hidden, thumbs visibility: hidden, the viewport tabindex="-1", and corner/thumb size variables 0px. The overflow variables are unset, so var(--scroll-area-overflow-y-end, 40px) fallbacks apply until hydration.
  • Like Base UI, the four --scroll-area-overflow-* variables are registered as non-inherited (CSS.registerProperty, skipped in Safari) so deep subtrees don't restyle on every scroll. Children opt in with --scroll-area-overflow-y-start: inherit.
  • Native scrollbars are hidden with scrollbar-width: none (Chromium, Firefox, Safari 18.2+). Older Safari also needs .viewport::-webkit-scrollbar { display: none } in your stylesheet (the registry component adds [&::-webkit-scrollbar]:hidden).
  • Not implemented from Base UI: waiting for transform animations inside the viewport before re-measuring (call measure() after such an animation).

Styling hooks

Shared attributes (on root, viewport, content and scrollbar):

AttributeDescription
data-has-overflow-xPresent when the content is wider than the viewport.
data-has-overflow-yPresent when the content is taller than the viewport.
data-overflow-x-startPresent when there is overflow on the inline-start side.
data-overflow-x-endPresent when there is overflow on the inline-end side.
data-overflow-y-startPresent when there is overflow above.
data-overflow-y-endPresent when there is overflow below.
data-scrollingPresent while the user scrolls (per axis on scrollbar and thumb).
PartAttribute / variableDescription
root--scroll-area-corner-width, --scroll-area-corner-heightThe corner size (0px without a corner).
viewport--scroll-area-overflow-{x,y}-{start,end}Distance from each edge in pixels (non-inherited).
scrollbardata-orientationvertical or horizontal.
scrollbardata-hoveringPresent while the pointer is over the root.
scrollbar--scroll-area-thumb-height / --scroll-area-thumb-widthThe thumb length for its orientation.
scrollbarhiddenIts axis doesn't overflow (not keepMounted).
thumbdata-orientation, data-scrollingAs on its scrollbar.
cornerhiddenFewer than two scrollbars are needed.

A common pattern from Base UI's docs:

.scrollbar {
  opacity: 0;
  transition: opacity 150ms;
  pointer-events: none;
}
.scrollbar[data-hovering],
.scrollbar[data-scrolling] {
  opacity: 1;
  pointer-events: auto;
}
.scrollbar[data-scrolling] {
  transition-duration: 0ms;
}

.viewport {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black min(40px, var(--scroll-area-overflow-y-start)),
    black calc(100% - min(40px, var(--scroll-area-overflow-y-end, 40px))),
    transparent 100%
  );
}