Skip to main content

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#

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>
  )
}

Playground#

Activity entry 1

Project updates and decisions.

Activity entry 2

Project updates and decisions.

Activity entry 3

Project updates and decisions.

Activity entry 4

Project updates and decisions.

Activity entry 5

Project updates and decisions.

Activity entry 6

Project updates and decisions.

Activity entry 7

Project updates and decisions.

Activity entry 8

Project updates and decisions.

Activity entry 9

Project updates and decisions.

Activity entry 10

Project updates and decisions.

Activity entry 11

Project updates and decisions.

Activity entry 12

Project updates and decisions.

Props
Slots

Anatomy#

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.

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.

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.

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.

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
data-orientationSlot: scroll-areaDescription: Stores the rendered orientation (horizontal or vertical).
data-shadow-endSlot: scroll-areaDescription: —
data-shadow-startSlot: scroll-areaDescription: —

Props#

Renders a <div> element by default.

Prop