# Scroll Area

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

```tsx
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

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/installation), and paste `states.ts` from the same page,
   which is where the viewport's focus ring comes from.

3. Copy and paste the following code into your project.

   ```tsx title="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 }
   ```

4. Update the import paths to match your project setup.

## Usage

```tsx
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
```

```tsx
<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.

```text
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.

```tsx
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](https://base-ui.com/react/components/scroll-area).
Both parts also take `xstyle` and `className`.

### ScrollArea

| Prop                    | Type                                                           | Default |
| ----------------------- | -------------------------------------------------------------- | ------- |
| `viewportProps`         | `ScrollAreaPrimitive.Viewport.Props & { xstyle?, className? }` | —       |
| `overflowEdgeThreshold` | `number \| Partial<{ xStart; xEnd; yStart; yEnd }>`            | `0`     |
| `xstyle`                | `StyleXStyles`                                                 | —       |
| `className`             | `string`                                                       | —       |

`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

| Prop          | Type                         | Default      |
| ------------- | ---------------------------- | ------------ |
| `orientation` | `"vertical" \| "horizontal"` | `"vertical"` |
| `keepMounted` | `boolean`                    | `false`      |

### scrollAreaStyles

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

| Key            | What it is                                         |
| -------------- | -------------------------------------------------- |
| `viewport`     | Fills the root, inherits its radius, plus the ring |
| `scrollbar`    | The track: flex, 1px gutter, no touch or selection |
| `orientations` | `vertical` and `horizontal` track geometry         |
| `thumb`        | The 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

| Attribute                                               | On                     | Effect                                                     |
| ------------------------------------------------------- | ---------------------- | ---------------------------------------------------------- |
| `data-slot="scroll-area"`                               | the root               | Targets the frame from a parent                            |
| `data-slot="scroll-area-viewport"`                      | the scroller           | Targets the element that scrolls                           |
| `data-slot="scroll-area-content"`                       | the content box        | Base UI watches its size to move the thumb                 |
| `data-slot="scroll-area-scrollbar"`, `data-orientation` | a track                | Targets one bar; Base UI writes the orientation            |
| `data-slot="scroll-area-thumb"`                         | a grip                 | Targets the thumb                                          |
| `data-slot="scroll-area-corner"`                        | the corner             | Present only while both bars are                           |
| `data-has-overflow-x`, `data-has-overflow-y`            | root, viewport, tracks | Base UI writes them while an axis overflows                |
| `data-overflow-x-start`, `-x-end`, `-y-start`, `-y-end` | root, viewport, tracks | Base UI writes them while content is hidden past that edge |
| `data-scrolling`, `data-hovering`                       | tracks                 | Base UI writes them during a scroll and while hovered      |
