---
title: ScrollArea
description: Scroll native content with optional fades at overflowing edges.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# ScrollArea

> Scroll native content with optional fades at overflowing edges.

Use ScrollArea to constrain overflowing content while keeping native scrolling. Enable `shadow` to fade the edges that have more content beyond the viewport.

## Basic usage

```tsx
import { For } from 'solid-js'
import { ScrollArea } from 'moraine'

export function Example() {
  return (
    <ScrollArea class="h-48 w-full" shadow role="region" aria-label="Recent activity">
      <For each={Array.from({ length: 12 }, (_, index) => index + 1)}>
        {(entry) => <p class="py-2 text-sm">Activity entry {entry}</p>}
      </For>
    </ScrollArea>
  )
}
```

## Anatomy

```text
ScrollArea [component; slot=root; <div>]
```

## Usage

### Native scrolling and sizing

Set a height or maximum height for vertical scrolling and a width for horizontal scrolling. The component has one `<div>` root and renders children directly inside it, so layout utilities such as `flex`, `gap-4`, and `space-y-3` apply to your content.

The root is keyboard-focusable by default with `tabIndex={0}`. Supply `role="region"` and an accessible label when the content forms a named region. Use `tabIndex={-1}` when the container should be excluded from the tab order. Native scroll events and refs are forwarded to the root.

### Optional shadows

Shadows are disabled by default. Set `shadow` to enable CSS mask fades at overflowing edges. A container at the start shows a trailing fade; between the edges it shows both fades; at the end it shows a leading fade. Content that fits has no automatic fade.

The mask fades content into the background behind the container. Scroll position, content changes, and size changes update the visible edges.

```tsx
import { ScrollArea } from 'moraine'
import { For } from 'solid-js'

const entries = [
  'Reviewed the project brief.',
  'Agreed on the navigation structure.',
  'Added the first component examples.',
  'Checked keyboard interactions.',
  'Updated the shared theme tokens.',
  'Tested layouts on smaller screens.',
  'Published the accessibility notes.',
  'Prepared the next release.',
]

export function Shadows() {
  return (
    <div class="gap-6 grid w-full sm:grid-cols-2">
      <For each={[false, true]}>
        {(shadow) => (
          <div class="space-y-3">
            <p class="text-sm font-medium">{shadow ? 'Shadows enabled' : 'Native scrolling'}</p>
            <ScrollArea
              shadow={shadow}
              class="pr-3 h-48 space-y-1"
              role="region"
              aria-label={shadow ? 'Activity with edge shadows' : 'Activity without edge shadows'}
            >
              <For each={entries}>
                {(entry, index) => (
                  <div class="py-1.5 flex gap-2 items-baseline">
                    <span class="text-xs text-muted-foreground shrink-0">Update {index() + 1}</span>
                    <p class="text-sm">{entry}</p>
                  </div>
                )}
              </For>
            </ScrollArea>
          </div>
        )}
      </For>
    </div>
  )
}
```

### Horizontal scrolling and RTL

Set `orientation="horizontal"` and make the content wider than the viewport. Use `hideScrollbar` to hide the native scrollbar while retaining wheel, touch, and keyboard scrolling. Horizontal edge detection also supports `dir="rtl"`; `left` and `right` refer to physical edges.

```tsx
import { ScrollArea } from 'moraine'
import { For } from 'solid-js'

const milestones = ['Planning', 'Design', 'Prototype', 'Implementation', 'Review', 'Release']

export function Horizontal() {
  return (
    <div class="w-full space-y-6">
      <For each={['ltr', 'rtl'] as const}>
        {(direction) => (
          <div class="space-y-3">
            <p class="text-sm font-medium">
              {direction === 'ltr' ? 'Left to right' : 'Right to left'}
            </p>
            <ScrollArea
              orientation="horizontal"
              shadow
              hideScrollbar
              dir={direction}
              class="pb-2 flex gap-3 max-w-lg w-full"
              role="region"
              aria-label={`${direction.toUpperCase()} project milestones`}
            >
              <For each={milestones}>
                {(milestone, index) => (
                  <div class="py-1.5 ps-3 border-s border-border shrink-0 w-32">
                    <p class="text-xs text-muted-foreground">Step {index() + 1}</p>
                    <p class="text-sm font-medium mt-1">{milestone}</p>
                  </div>
                )}
              </For>
            </ScrollArea>
          </div>
        )}
      </For>
    </div>
  )
}
```

### Shadow size and visibility

The default fade size is `40px`. Pass `shadowSize` to set its length in pixels. `offset` adds a margin near each edge within which that edge's automatic fade remains hidden.

`visibility="auto"` follows measured overflow. Choose `top`, `bottom`, or `both` for vertical scrolling, and `left`, `right`, or `both` for horizontal scrolling to force visible edges. `none` suppresses every fade. Manual visibility still requires `shadow`.

`onVisibilityChange` reports measured overflow changes while shadows are enabled, including when visibility is forced. It receives `top`, `bottom`, `left`, `right`, `both`, or `none`.

```tsx
import { Button, ScrollArea } from 'moraine'
import type { ScrollAreaT } from 'moraine'
import { For, createSignal } from 'solid-js'

const visibilityOptions: ScrollAreaT.Visibility[] = ['auto', 'top', 'bottom', 'both', 'none']

export function Visibility() {
  const [visibility, setVisibility] = createSignal<ScrollAreaT.Visibility>('auto')
  const [overflow, setOverflow] = createSignal<Exclude<ScrollAreaT.Visibility, 'auto'>>('none')

  return (
    <div class="w-full space-y-4">
      <div class="flex flex-wrap gap-2" role="group" aria-label="Shadow visibility">
        <For each={visibilityOptions}>
          {(option) => (
            <Button
              size="sm"
              variant={visibility() === option ? 'default' : 'outline'}
              aria-pressed={visibility() === option}
              onClick={() => setVisibility(option)}
            >
              {option}
            </Button>
          )}
        </For>
      </div>
      <ScrollArea
        shadow
        shadowSize={24}
        offset={8}
        visibility={visibility()}
        onVisibilityChange={setOverflow}
        class="pr-3 h-48 max-w-sm w-full space-y-1"
        role="region"
        aria-label="Visibility example activity"
      >
        <For each={Array.from({ length: 10 }, (_, index) => index + 1)}>
          {(entry) => <p class="text-sm py-1.5">Activity entry {entry}</p>}
        </For>
      </ScrollArea>
      <p class="text-sm text-muted-foreground">
        Measured overflow: <output aria-live="polite">{overflow()}</output>
      </p>
    </div>
  )
}
```

### Theme and instance overrides

Configure `scrollArea.defaultVariants` to enable shadows or hide scrollbars across a provider. The recipe exposes `--scroll-area-shadow-size` and the gradient stop lists `--scroll-area-shadow-start` and `--scroll-area-shadow-end`. Single-edge masks use the corresponding stop list; the double-edge mask combines both. Keep size-dependent stops expressed with `var(--scroll-area-shadow-size)` so `shadowSize` continues to affect them.

For the size variable, overrides apply in this order, from highest to lowest: root `style`, `styles.root`, an explicit `shadowSize`, then recipe and Theme defaults. Omitting `shadowSize` preserves the Theme's value. Recipe variables are written on the root, so an ancestor's same-named CSS variable does not replace the local default.

Use `class`, `classes.root`, `style`, or `styles.root` for instance styling.

```tsx
import { MoraineProvider, ScrollArea } from 'moraine'
import { defineTheme } from 'moraine/theme'
import { For } from 'solid-js'

const theme = defineTheme({
  scrollArea: {
    defaultVariants: { shadow: true, hideScrollbar: true },
    base: {
      '--scroll-area-shadow-size': '24px',
      '--scroll-area-shadow-start': 'transparent, black calc(var(--scroll-area-shadow-size) * 1.5)',
      '--scroll-area-shadow-end':
        'black calc(100% - var(--scroll-area-shadow-size) * 1.5), transparent',
    },
  },
})

export function Theme() {
  return (
    <MoraineProvider theme={theme}>
      <div class="gap-6 grid w-full sm:grid-cols-2">
        <For each={[undefined, 12]}>
          {(shadowSize) => (
            <div class="space-y-3">
              <p class="text-sm font-medium">
                {shadowSize === undefined ? 'Theme size: 24px' : 'Instance size: 12px'}
              </p>
              <ScrollArea
                shadowSize={shadowSize}
                class="h-48 space-y-1"
                role="region"
                aria-label={
                  shadowSize === undefined ? 'Theme defaults example' : 'Instance override example'
                }
              >
                <For each={Array.from({ length: 10 }, (_, index) => index + 1)}>
                  {(entry) => <p class="text-sm py-1.5">Project update {entry}</p>}
                </For>
              </ScrollArea>
            </div>
          )}
        </For>
      </div>
    </MoraineProvider>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-orientation` | `scroll-area` | Stores the rendered orientation (horizontal or vertical). |
| `data-shadow-end` | `scroll-area` | — |
| `data-shadow-start` | `scroll-area` | — |

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| hideScrollbar | boolean \| undefined | false | Hide the native scrollbar while retaining scrolling. |
| offset | number \| undefined | 0 | Distance from an edge within which its shadow stays hidden. |
| onVisibilityChange | ((visibility: Exclude<Visibility, 'auto'>) => void) \| undefined | — | Called when measured overflow edges change while shadow is enabled. |
| orientation | 'horizontal' \| 'vertical' \| undefined | 'vertical' | Scroll axis. |
| shadow | boolean \| undefined | false | Whether overflowing edges fade out. |
| shadowSize | number \| undefined | 40 | Edge fade size in pixels. |
| visibility | Visibility \| undefined | 'auto' | Visible shadow edges. Manual visibility still requires shadow. |
| children | JSX.Element \| undefined | — | Content rendered directly inside the scroll container. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |
