# Scroll Area

A native scroller with the browser scrollbar swapped for one you style yourself.

Scroll Area keeps the browser’s own scrolling and hides only the scrollbar it
draws, so you can put your own in its place. The wheel, the trackpad, the
keyboard and the touch fling all stay native, and the bar becomes a few
elements you style like everything else.

::component-preview{name="ScrollAreaPreview"}
```vue
<template>
  <div class="flex w-72 flex-col gap-6">
    <ScrollArea.Root :class="root">
      <ScrollArea.Viewport :class="[viewport, 'h-48']" aria-label="Releases">
        <ScrollArea.Content class="flex flex-col gap-1 pr-3">
          <div v-for="entry in releases" :key="entry" :class="row">
            {{ entry }}
          </div>
        </ScrollArea.Content>
      </ScrollArea.Viewport>

      <ScrollArea.Scrollbar :class="scrollbar" force-mount>
        <ScrollArea.Thumb :class="thumb" />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>

    <ScrollArea.Root :class="root">
      <ScrollArea.Viewport :class="viewport" aria-label="Tags">
        <ScrollArea.Content class="flex gap-1 pb-3">
          <span v-for="tag in tags" :key="tag" :class="chip">{{ tag }}</span>
        </ScrollArea.Content>
      </ScrollArea.Viewport>

      <ScrollArea.Scrollbar
        :class="scrollbar"
        force-mount
        orientation="horizontal"
      >
        <ScrollArea.Thumb :class="thumb" />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>
  </div>
</template>

<script setup lang="ts">
import { ScrollArea } from '@maas/mirror/vue'

const releases = Array.from({ length: 20 }, (_, index) => {
  return `2.0.0-beta.${20 - index}`
})

const tags = [
  'headless',
  'unstyled',
  'accessible',
  'composable',
  'typed',
  'themeable',
]

const root = 'rounded-component-md border-surface border p-1.5'

const viewport = [
  'w-full',
  'rounded-component-compact-md outline-4 outline-transparent',
  'focus-visible:focus-ring',
].join(' ')

const row = [
  'rounded-component-compact-md bg-surface-higher text-surface-muted',
  'type-component-sm -number px-3 py-2',
].join(' ')

const chip = [
  'rounded-component-compact-md bg-surface-higher text-surface-muted',
  'type-component-sm px-3 py-1.5 whitespace-nowrap',
].join(' ')

const scrollbar = [
  'w-2 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-auto',
  'opacity-0 transition-opacity duration-150 ease-linear',
  'data-[hovering=true]:opacity-100 data-[scrolling=true]:opacity-100',
].join(' ')

const thumb = 'bg-primary-muted rounded-full'
</script>
```
::

## Usage guidelines

- Give the viewport a size, either directly or through the root, since it fills
  the root and the root sizes itself from whatever you put around it.
- Leave room for the scrollbar with padding on the content rather than a margin
  on the viewport, or the overlay sits on top of the last few pixels of text.
- The thumb is sized as a fraction of the track, so a taller viewport gets a
  taller thumb; use `--mirror-scroll-area-thumb-min-size` for the point where
  very long content would otherwise leave nothing to grab.
- Give the viewport an `aria-label` when it holds no focusable elements, since
  it becomes a tab stop of its own and a screen reader announces it.

## Anatomy

Assemble the parts. One `ScrollArea.Scrollbar` per axis you want a bar on.

::component-anatomy
---
parts:
  - name: ScrollArea.Root
    required: true
    description: 'Renders a <div> and owns the metrics every other part reads.'
    children:
      - name: ScrollArea.Viewport
        required: true
        description: The native scroller, with its own scrollbar hidden.
        children:
          - name: ScrollArea.Content
            required: true
            description: Wraps the content and reports its size back.
      - name: ScrollArea.Scrollbar
        description: One per axis. Rendered while the area is hovered or moving.
        children:
          - name: ScrollArea.Thumb
            required: true
            description: Sized and positioned from the viewport metrics.
      - name: ScrollArea.Corner
        description: The square where two scrollbars meet.
---
::

```vue
<script setup lang="ts">
import { ScrollArea } from '@maas/mirror/vue'
</script>

<template>
  <ScrollArea.Root>
    <ScrollArea.Viewport>
      <ScrollArea.Content>…</ScrollArea.Content>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar orientation="vertical">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Scrollbar orientation="horizontal">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Corner />
  </ScrollArea.Root>
</template>
```

## Examples

### Showing the edges

Every part carries an attribute for each edge the content runs past, which is
what a fade at the top or the bottom of a list styles against.

```vue
<template>
  <ScrollArea.Root class="fades">
    <ScrollArea.Viewport>
      <ScrollArea.Content>…</ScrollArea.Content>
    </ScrollArea.Viewport>
  </ScrollArea.Root>
</template>

<style>
.fades::before {
  opacity: 0;
  transition: opacity 150ms linear;
}

.fades[data-overflow-y-start='true']::before {
  opacity: 1;
}
</style>
```

`options.overflowEdgeThreshold` is how far the viewport has to be from an edge
before it counts as away from it, in pixels. One number covers all four edges,
an object covers the ones it names.

```vue
<template>
  <ScrollArea.Root :options="{ overflowEdgeThreshold: 16 }" />
  <ScrollArea.Root
    :options="{ overflowEdgeThreshold: { yStart: 16, yEnd: 24 } }"
  />
</template>
```

### Keeping the scrollbars around

A scrollbar is mounted while the pointer is over the area or the content is
moving, and leaves once `options.hideDelay` has passed. `force-mount` on
`ScrollArea.Scrollbar` holds that scrollbar in the DOM instead, which a fade
needs, and which keeps a scrollbar in place that should never come and go.

```vue
<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar class="scrollbar" force-mount>
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
  </ScrollArea.Root>
</template>

<style>
.scrollbar {
  opacity: 0;
  transition: opacity 150ms linear;
}

.scrollbar[data-hovering='true'],
.scrollbar[data-scrolling='true'] {
  opacity: 1;
}
</style>
```

Where the scrollbar should animate out rather than disappear, give it a
`transition` and let the presence wrapper hold it until the animation ends.

```vue
<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar transition="scrollbar" />
  </ScrollArea.Root>
</template>
```

The two axes rarely want the same treatment, so each scrollbar carries both keys
itself.

```vue
<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar force-mount orientation="vertical" />
    <ScrollArea.Scrollbar
      orientation="horizontal"
      transition="scrollbar-slide"
    />
  </ScrollArea.Root>
</template>
```

Where they should match, spread one object over both.

```vue
<script setup lang="ts">
const bar = { transition: 'scrollbar' }
</script>

<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar v-bind="bar" orientation="vertical" />
    <ScrollArea.Scrollbar v-bind="bar" orientation="horizontal" />
  </ScrollArea.Root>
</template>
```

`force-mount` wins over `transition`, since a scrollbar that never leaves the
DOM has nothing to transition. Fade a kept scrollbar from `data-hovering` and
`data-scrolling` instead.

### Both axes at once

Two scrollbars and a corner. Each scrollbar is rendered only for an axis that
overflows, and both stop short of the corner on their own.

```vue
<template>
  <ScrollArea.Root>
    <ScrollArea.Viewport class="h-64">
      <ScrollArea.Content>…</ScrollArea.Content>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar orientation="vertical">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Scrollbar orientation="horizontal">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Corner />
  </ScrollArea.Root>
</template>
```

### Reaching the scroll area from anywhere

Give the root an `id` and `useMirrorScrollArea(id)` reads the metrics and moves
the viewport from anywhere in the app.

```vue
<template>
  <Button @click="scrollTo({ top: 0, behavior: 'smooth' })">Back to top</Button>
</template>

<script setup lang="ts">
const { scrollTo, hasOverflowY } = useMirrorScrollArea(ScrollAreaId.Releases)
</script>
```

## API reference

**Module.** A bundled `options` object, and `useMirrorScrollArea(id)` as the
programmatic API. Every part takes `id` to resolve a scroll area it is not
nested inside.

### `ScrollArea.Root`

Renders a `<div>` and owns the metrics, the hover state and the corner size. It
carries a `dir` attribute wherever `options.dir` is set.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: '[The instance ID](#reaching-the-scroll-area-from-anywhere).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: ScrollAreaOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: dir
        description: 'Direction for the horizontal thumb and the drag. [Inherited](/components/composition#text-direction) when unset.'
      - label: '''ltr'' | ''rtl'''
      - label: inherited
        plaintext: true
  - items:
      - label: overflowEdgeThreshold
        description: 'How far from an [edge](#showing-the-edges) counts as away from it, in pixels.'
      - label: 'number | { xStart, xEnd, yStart, yEnd }'
      - label: '0'
  - items:
      - label: hideDelay
        description: Milliseconds before the area stops counting as scrolling.
      - label: number
      - label: '500'
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: scrolling
        description: The viewport starts moving, and again once `options.hideDelay` has passed.
      - label: boolean
---
::

#### Slot props

The overflow state, which every part publishes: `hasOverflowX`,
`hasOverflowY`, `overflowXStart`, `overflowXEnd`, `overflowYStart`,
`overflowYEnd`, `cornerHidden` and `scrolling`, all booleans.

### `ScrollArea.Viewport`

Renders a `<div>` that scrolls natively, with its own scrollbar hidden rather
than removed. It becomes a tab stop as soon as it overflows and holds nothing
focusable of its own.

#### Slot props

The overflow state.

### `ScrollArea.Content`

Renders a `<div>` around the content and reports its size, so a list that grows
resizes the thumb with nothing else to call.

#### Slot props

The overflow state.

### `ScrollArea.Scrollbar`

Renders a `<div>` positioned along its axis, inset by the corner where the two
scrollbars would otherwise meet. A pointerdown on the bare track pages the
viewport towards the pointer.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the scroll area this belongs to.
      - label: string
      - label: injected
        plaintext: true
  - items:
      - label: orientation
        description: Which axis this scrollbar drives.
      - label: '''horizontal'' | ''vertical'''
      - label: '''vertical'''
  - items:
      - label: forceMount
        description: 'Keeps this scrollbar [in the DOM](#keeping-the-scrollbars-around) whether or not the axis is in use.'
      - label: boolean
      - label: 'false'
  - items:
      - label: transition
        description: 'Vue [transition name](#keeping-the-scrollbars-around) this scrollbar leaves under.'
      - label: string
      - label: undefined
---
::

#### Slot props

The overflow state, plus the three below.

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: '''horizontal'' | ''vertical'''
  - items:
      - label: hovering
        description: Mirrors `data-hovering`.
      - label: boolean
  - items:
      - label: dragging
        description: Mirrors `data-dragging`.
      - label: boolean
---
::

### `ScrollArea.Thumb`

Renders a `<div>` sized to the visible fraction of the content and positioned
from the scroll offset. Dragging it captures the pointer and scrolls the
viewport.

#### Slot props

The overflow state, plus `orientation` and `dragging`.

### `ScrollArea.Corner`

Renders a `<div>` in the corner where the two scrollbars meet, as wide as the
vertical scrollbar and as tall as the horizontal one. Both measurements are zero
until both axes overflow, so it takes up no room in an area that scrolls one
way.

#### Slot props

The overflow state.

### Composable

`useMirrorScrollArea(id)` reaches a scroll area from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: viewportElement
        description: The scrolling element, once it has mounted.
      - label: 'ComputedRef<HTMLElement | null>'
        escape: true
  - items:
      - label: metrics
        description: The six numbers everything else is derived from.
      - label: ComputedRef<ScrollAreaMetrics>
        escape: true
  - items:
      - label: overflow
        description: The overflow state as one object, in the same names as the data attributes.
      - label: ComputedRef<ScrollAreaOverflowState>
        escape: true
  - items:
      - label: hasOverflowX
        description: Mirrors `data-has-overflow-x`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: hasOverflowY
        description: Mirrors `data-has-overflow-y`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: scrolling
        description: Mirrors `data-scrolling`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: hovering
        description: Mirrors `data-hovering`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: dragging
        description: The axis being dragged, if any.
      - label: 'ComputedRef<''horizontal'' | ''vertical'' | null>'
        escape: true
  - items:
      - label: measure
        description: Re-reads the viewport, for content that changes size without resizing.
      - label: () => void
        escape: true
  - items:
      - label: scrollTo
        description: Scrolls the viewport. Takes the native `ScrollToOptions`.
      - label: '(options: ScrollToOptions) => void'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_scroll_area_context
        description: Any `ScrollArea` part rendered outside `ScrollArea.Root` with no `id` of its own.
  - items:
      - label: missing_scroll_area_scrollbar
        description: A `ScrollArea.Thumb` rendered outside a `ScrollArea.Scrollbar`.
  - items:
      - label: duplicate_scrollbar_orientation
        description: Two `ScrollArea.Scrollbar` parts for the same axis in one scroll area.
---
::

## Accessibility

Scrolling is the browser’s, so every key below works on the focused viewport
without the component listening for any of them. Where the content holds
something focusable the viewport stays out of the tab order and the focused
element scrolls it into view instead; where it holds nothing focusable the
viewport becomes a tab stop of its own, which is why it needs an `aria-label`.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: '`ArrowUp` / `ArrowDown`'
        plaintext: true
      - label: Scroll by a line.
        plaintext: true
  - items:
      - label: '`ArrowLeft` / `ArrowRight`'
        plaintext: true
      - label: Scroll sideways by a line.
        plaintext: true
  - items:
      - label: '`PageUp` / `PageDown`'
        plaintext: true
      - label: Scroll by a viewport.
        plaintext: true
  - items:
      - label: '`Home` / `End`'
        plaintext: true
      - label: Jump to the top or the bottom.
        plaintext: true
  - items:
      - label: '`Space` / `Shift` + `Space`'
        plaintext: true
      - label: Scroll down or up by a viewport.
        plaintext: true
---
::

The scrollbars are decoration: they carry no role and no tab stop, and
everything they do with a pointer the keyboard already does through the
viewport.
