Scroll Area

Augments native scroll functionality for custom, cross-browser styling.

import * as stylex from "@stylexjs/stylex"
import { ScrollArea } from "@/components/ui/scroll-area"
import { VStack } from "@/components/ui/stack"
import { leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  area: {
    height: "18rem",
    width: "12rem",
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
  },
  heading: { fontSize: text.sm, lineHeight: leading.sm, fontWeight: 500 },
  tag: {
    paddingBlockEnd: space.s2,
    borderBlockEndWidth: 1,
    borderBlockEndStyle: "solid",
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
})

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

export default function ScrollAreaDemo() {
  return (
    <ScrollArea xstyle={styles.area}>
      <VStack padding={4} gap={4}>
        <h4 {...stylex.props(styles.heading)}>Tags</h4>
        <VStack gap={2}>
          {tags.map((tag) => (
            <div key={tag} {...stylex.props(styles.tag)}>
              {tag}
            </div>
          ))}
        </VStack>
      </VStack>
    </ScrollArea>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page, which is where the viewport's focus ring comes from.

Copy and paste the following code into your project.

components/ui/scroll-area.tsx
"use client"

import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { states } from "@/theme/states"
import { color, duration, ease, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  viewport: {
    width: "100%",
    height: "100%",
    borderRadius: "inherit",
    transitionProperty: "box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
  },
  scrollbar: {
    display: "flex",
    touchAction: "none",
    userSelect: "none",
    padding: 1,
  },
  thumb: {
    flexGrow: 1,
    flexShrink: 1,
    flexBasis: "0%",
    borderRadius: radius.full,
    backgroundColor: color.border,
  },
})

const orientations = stylex.create({
  vertical: {
    width: space.s2_5,
    paddingInlineStart: 2,
  },
  horizontal: {
    height: space.s2_5,
    flexDirection: "column",
    paddingBlockStart: 2,
  },
})

const scrollAreaStyles = {
  viewport: [styles.viewport, states.focusRing],
  scrollbar: styles.scrollbar,
  orientations,
  thumb: styles.thumb,
}

type ScrollBarOrientation = keyof typeof orientations

type StyleProps = {
  xstyle?: StyleXStyles
  className?: string
}

type ScrollAreaViewportProps = Omit<ScrollAreaPrimitive.Viewport.Props, "className"> & StyleProps

type ScrollAreaProps = Omit<ScrollAreaPrimitive.Root.Props, "className"> &
  StyleProps & {
    viewportProps?: ScrollAreaViewportProps
  }

function ScrollArea({ xstyle, className, viewportProps, children, ...props }: ScrollAreaProps) {
  const sx = stylex.props(xstyle)
  const {
    xstyle: viewportXstyle,
    className: viewportClassName,
    ...viewportRest
  } = viewportProps ?? {}
  const viewportSx = stylex.props(styles.viewport, states.focusRing, viewportXstyle)

  return (
    <ScrollAreaPrimitive.Root
      data-slot="scroll-area"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    >
      <ScrollAreaPrimitive.Viewport
        data-slot="scroll-area-viewport"
        {...viewportSx}
        {...viewportRest}
        className={clsx(viewportSx.className, viewportClassName)}
      >
        <ScrollAreaPrimitive.Content data-slot="scroll-area-content">
          {children}
        </ScrollAreaPrimitive.Content>
      </ScrollAreaPrimitive.Viewport>
      <ScrollBar />
      <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
    </ScrollAreaPrimitive.Root>
  )
}

type ScrollBarProps = Omit<ScrollAreaPrimitive.Scrollbar.Props, "className" | "orientation"> &
  StyleProps & {
    orientation?: ScrollBarOrientation
  }

function ScrollBar({ xstyle, className, orientation = "vertical", ...props }: ScrollBarProps) {
  const sx = stylex.props(styles.scrollbar, orientations[orientation], xstyle)

  return (
    <ScrollAreaPrimitive.Scrollbar
      data-slot="scroll-area-scrollbar"
      orientation={orientation}
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    >
      <ScrollAreaPrimitive.Thumb data-slot="scroll-area-thumb" {...stylex.props(styles.thumb)} />
    </ScrollAreaPrimitive.Scrollbar>
  )
}

export { ScrollArea, ScrollBar, scrollAreaStyles, type ScrollBarOrientation }

Update the import paths to match your project setup.

Usage

import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
<ScrollArea xstyle={styles.area}>Your scrollable content here.</ScrollArea>

Size and frame the root through xstyle. The viewport fills it and inherits its rounding, so a border and a radius on the root clip the content and shape the focus ring at once. ScrollArea renders the viewport, a vertical ScrollBar and the corner itself.

ScrollArea
└── ScrollBar

The scrolling element is a real scroll container with the native bar hidden, so the wheel, the keyboard and touch all work as they do on any overflow box. A scrollbar is mounted only while its axis overflows.

Horizontal

Add a ScrollBar with orientation="horizontal" for content wider than the frame. Give the content a max-content width or it wraps instead of overflowing.

import * as stylex from "@stylexjs/stylex"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { HStack } from "@/components/ui/stack"
import { color, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  area: {
    width: "100%",
    maxWidth: "24rem",
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    whiteSpace: "nowrap",
  },
  row: { width: "max-content" },
  figure: { flexShrink: 0 },
  frame: { overflow: "hidden", borderRadius: radius.md },
  image: { display: "block", aspectRatio: "3 / 4", objectFit: "cover" },
  caption: {
    paddingBlockStart: space.s2,
    fontSize: text.xs,
    lineHeight: leading.xs,
    color: color.mutedForeground,
  },
  artist: { fontWeight: 600, color: color.foreground },
})

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 default function ScrollAreaHorizontal() {
  return (
    <ScrollArea xstyle={styles.area}>
      <HStack gap={4} padding={4} xstyle={styles.row}>
        {works.map((artwork) => (
          <figure key={artwork.artist} {...stylex.props(styles.figure)}>
            <div {...stylex.props(styles.frame)}>
              <img
                src={artwork.art}
                alt={`Work by ${artwork.artist}`}
                width={300}
                height={400}
                {...stylex.props(styles.image)}
              />
            </div>
            <figcaption {...stylex.props(styles.caption)}>
              Photo by <span {...stylex.props(styles.artist)}>{artwork.artist}</span>
            </figcaption>
          </figure>
        ))}
      </HStack>
      <ScrollBar orientation="horizontal" />
    </ScrollArea>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI ScrollArea part of the same name and accepts its props. See the Base UI reference. Both parts also take xstyle and className.

ScrollArea

PropTypeDefault
viewportPropsScrollAreaPrimitive.Viewport.Props & { xstyle?, className? }—
overflowEdgeThresholdnumber | Partial<{ xStart; xEnd; yStart; yEnd }>0
xstyleStyleXStyles—
classNamestring—

ScrollArea passes viewportProps to the element that scrolls. Put a ref there to scroll it from code, an onScroll to watch it, or className="scroll-fade-y" to fade its edges with the mask from interop.css.

ScrollBar

PropTypeDefault
orientation"vertical" | "horizontal""vertical"
keepMountedbooleanfalse

scrollAreaStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
viewportFills the root, inherits its radius, plus the ring
scrollbarThe track: flex, 1px gutter, no touch or selection
orientationsvertical and horizontal track geometry
thumbThe rounded border-coloured grip

viewport is two styles, the scroller's own box and states.focusRing from theme/states.ts. The frame is on the root, so the viewport has no border of its own and only the ring's halo shows.

Data attributes

AttributeOnEffect
data-slot="scroll-area"the rootTargets the frame from a parent
data-slot="scroll-area-viewport"the scrollerTargets the element that scrolls
data-slot="scroll-area-content"the content boxBase UI watches its size to move the thumb
data-slot="scroll-area-scrollbar", data-orientationa trackTargets one bar; Base UI writes the orientation
data-slot="scroll-area-thumb"a gripTargets the thumb
data-slot="scroll-area-corner"the cornerPresent only while both bars are
data-has-overflow-x, data-has-overflow-yroot, viewport, tracksBase UI writes them while an axis overflows
data-overflow-x-start, -x-end, -y-start, -y-endroot, viewport, tracksBase UI writes them while content is hidden past that edge
data-scrolling, data-hoveringtracksBase UI writes them during a scroll and while hovered