# Scrollable Area

## Usage

`ScrollableArea` is a native scroll viewport with an observed content box. It enters the tab order only while a requested axis actually overflows. `label` is required.

```vue
<script setup lang="ts">
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
</script>

<template>
  <ScrollableArea label="Notes" :height="240">
    <p>Long content</p>
  </ScrollableArea>
</template>
```

`axis` is `block` (the default), `inline`, or `both`. The component maps those logical axes through writing mode and direction. `overscroll` is `allow` or `contain`. `stickyContainment` is `whenScrollable` or `always`. Padding props apply to the content box and default to 0. `isFullBleed` lets the viewport escape an ancestor's padding without changing the content padding.

Until overflow is measured, the requested axis uses `overflow: clip`. After a measurement, an overflowing axis uses `auto`. `always` keeps the viewport as a sticky boundary even when the content fits. The tolerance is 1px.

`useScrollableArea` is the same measurement for a viewport and content box you already own. `useScrollOverflow` reports the inline start and end edges of one horizontal scroller.

## Working with ScrollbarsProvider

When a [`ScrollbarsProvider`](/en/components/scrollbars-provider) sits above it, `ScrollableArea` switches to the custom overlay scrollbar on its own. You change no code. Without a provider you still get native scrollbars.

To keep the native scrollbar on one area, set `:scrollbars="false"`.

### Anatomy

- **Viewport**: The root native scroll container, accessible name owner, focus target while effective, and `astryx-scrollable-area` theme target.
- **Content box**: A real inner layout box observed together with the viewport. For inline scrolling it uses max-content inline sizing with a 100% minimum.

## Best practices

| Guidance | Practices |
| --- | --- |
| Do | Give every area a short label that names what keyboard users will scroll. |
| Do | Keep `overscroll="allow"` unless a nested area should stop the gesture. |
| Don't | Wrap it in another overflow container. One viewport should own the scroll. |
| Don't | Hide the scrollbar without another way to reach the overflow. |

## Examples

### Inline rail

axis inline scrolls the cards sideways. isFullBleed lets the rail reach the panel edges while the first card stays aligned with the heading.

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Link } from '@astryx-vue/core/Link'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { HStack, VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const files = [
  { id: 'brief', title: 'Q3 launch brief', meta: 'Edited 2 days ago' },
  { id: 'pricing', title: 'Pricing rework', meta: 'Edited 3 days ago' },
  { id: 'onboarding', title: 'Onboarding flow v4', meta: 'Edited 4 days ago' },
  { id: 'macros', title: 'Support macros', meta: 'Edited last week' },
  { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
  { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
  { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
]
</script>

<template>
  <Card :padding="4" :width="460">
    <VStack :gap="3">
      <HStack h-align="between" align="center">
        <VStack :gap="0.5">
          <p class="layout-title layout-title--page">
            Workspace files
          </p>
          <Text type="supporting">
            Recently opened
          </Text>
        </VStack>
        <Link href="#files">
          View all
        </Link>
      </HStack>
      <ScrollableArea
        axis="inline"
        is-full-bleed
        label="Recently opened files"
        :padding-block-end="4"
        :padding-block-start="0.5"
        :padding-inline="4"
        role="region"
      >
        <HStack :gap="2">
          <Card v-for="file in files" :key="file.id" :padding="3" variant="muted" :width="170">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </HStack>
      </ScrollableArea>
    </VStack>
  </Card>
</template>
```

### Overscroll

overscroll allow hands a finished gesture to the panel behind it. overscroll contain stops the gesture at the section edge.

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Grid } from '@astryx-vue/core/Grid'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const shared = [
  { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
  { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
  { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
  { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
  { id: 'tax', title: 'Tax region notes', meta: 'Hugo Bernard' },
  { id: 'sso', title: 'SSO rollout plan', meta: 'Sofia Marchetti' },
]

const archived = [
  { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
  { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
  { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
  { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
  { id: 'survey', title: 'Q4 survey results', meta: 'Archived in January' },
  { id: 'offsite', title: 'Offsite agenda', meta: 'Archived last year' },
]
</script>

<template>
  <Card :padding="0" :width="400">
    <ScrollableArea axis="block" :height="300" label="Workspace files" :padding="4" role="region">
      <VStack :gap="4">
        <VStack :gap="0.5">
          <p class="layout-title layout-title--page">
            Workspace files
          </p>
          <Text type="supporting">
            Two sections, two edge policies
          </Text>
        </VStack>
        <VStack :gap="2">
          <Text color="secondary" type="label">
            Shared with you
          </Text>
          <ScrollableArea axis="block" class="section-frame" :height="150" label="Shared with you" :padding="2">
            <Grid :columns="2" :gap="2">
              <Card v-for="file in shared" :key="file.id" :padding="3" variant="muted">
                <VStack :gap="1">
                  <Text :max-lines="1" weight="medium">
                    {{ file.title }}
                  </Text>
                  <Text :max-lines="1" type="supporting">
                    {{ file.meta }}
                  </Text>
                </VStack>
              </Card>
            </Grid>
          </ScrollableArea>
          <Text type="supporting">
            Scroll past the end and the panel keeps going.
          </Text>
        </VStack>
        <VStack :gap="2">
          <Text color="secondary" type="label">
            Archived
          </Text>
          <ScrollableArea axis="block" class="section-frame" :height="150" label="Archived" overscroll="contain" :padding="2">
            <Grid :columns="2" :gap="2">
              <Card v-for="file in archived" :key="file.id" :padding="3" variant="muted">
                <VStack :gap="1">
                  <Text :max-lines="1" weight="medium">
                    {{ file.title }}
                  </Text>
                  <Text :max-lines="1" type="supporting">
                    {{ file.meta }}
                  </Text>
                </VStack>
              </Card>
            </Grid>
          </ScrollableArea>
          <Text type="supporting">
            Scroll past the end and the panel stays where it is.
          </Text>
        </VStack>
      </VStack>
    </ScrollableArea>
  </Card>
</template>
```

### Sticky section labels

The scroll viewport is the sticky containing block, so section labels pin to the top of the list.

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Grid } from '@astryx-vue/core/Grid'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const sections = [
  {
    label: 'Recently opened',
    files: [
      { id: 'brief', title: 'Q3 launch brief', meta: 'Edited 2 days ago' },
      { id: 'pricing', title: 'Pricing rework', meta: 'Edited 3 days ago' },
      { id: 'onboarding', title: 'Onboarding flow v4', meta: 'Edited 4 days ago' },
      { id: 'macros', title: 'Support macros', meta: 'Edited last week' },
    ],
  },
  {
    label: 'Shared with you',
    files: [
      { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
      { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
      { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
      { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
    ],
  },
  {
    label: 'Archived',
    files: [
      { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
      { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
      { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
      { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
    ],
  },
]
</script>

<template>
  <Card :padding="0" :width="380">
    <ScrollableArea axis="block" :height="300" label="Workspace files" :padding="4" role="region">
      <VStack :gap="0.5" :padding-block-end="2">
        <p class="layout-title layout-title--page">
          Workspace files
        </p>
        <Text type="supporting">
          12 files across 3 sections
        </Text>
      </VStack>
      <section v-for="section in sections" :key="section.label">
        <div class="sticky-label">
          <Text color="secondary" type="label">
            {{ section.label }}
          </Text>
        </div>
        <Grid :columns="2" :gap="2">
          <Card v-for="file in section.files" :key="file.id" :padding="3" variant="muted">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </Grid>
      </section>
    </ScrollableArea>
  </Card>
</template>
```

### Sticky passthrough

A section that fits passes its sticky label outward. stickyContainment always keeps the next label inside its own box.

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Grid } from '@astryx-vue/core/Grid'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const shared = [
  { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
  { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
  { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
  { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
  { id: 'tax', title: 'Tax region notes', meta: 'Hugo Bernard' },
  { id: 'sso', title: 'SSO rollout plan', meta: 'Sofia Marchetti' },
]

const templates = [
  { id: 'weekly', title: 'Weekly report', meta: 'Used 24 times' },
  { id: 'checklist', title: 'Launch checklist', meta: 'Used 18 times' },
  { id: 'review', title: 'Design review', meta: 'Used 11 times' },
  { id: 'retro', title: 'Retro board', meta: 'Used 9 times' },
  { id: 'brief', title: 'Project brief', meta: 'Used 7 times' },
  { id: 'postmortem', title: 'Postmortem', meta: 'Used 4 times' },
]

const archived = [
  { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
  { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
  { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
  { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
  { id: 'survey', title: 'Q4 survey results', meta: 'Archived in January' },
  { id: 'offsite', title: 'Offsite agenda', meta: 'Archived last year' },
]
</script>

<template>
  <Card :padding="0" :width="400">
    <ScrollableArea axis="block" :height="300" label="Workspace files" :padding="4" role="region">
      <VStack :gap="0.5" :padding-block-end="2">
        <p class="layout-title layout-title--page">
          Workspace files
        </p>
        <Text type="supporting">
          18 files across 3 sections
        </Text>
      </VStack>
      <ScrollableArea axis="block" label="Shared with you">
        <div class="sticky-label">
          <Text color="secondary" type="label">
            Shared with you
          </Text>
        </div>
        <Grid :columns="2" :gap="2">
          <Card v-for="file in shared" :key="file.id" :padding="3" variant="muted">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </Grid>
      </ScrollableArea>
      <ScrollableArea axis="block" label="Templates" sticky-containment="always">
        <div class="sticky-label">
          <Text color="secondary" type="label">
            Templates
          </Text>
        </div>
        <Grid :columns="2" :gap="2">
          <Card v-for="file in templates" :key="file.id" :padding="3" variant="muted">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </Grid>
      </ScrollableArea>
      <div class="sticky-label">
        <Text color="secondary" type="label">
          Archived
        </Text>
      </div>
      <Grid :columns="2" :gap="2">
        <Card v-for="file in archived" :key="file.id" :padding="3" variant="muted">
          <VStack :gap="1">
            <Text :max-lines="1" weight="medium">
              {{ file.title }}
            </Text>
            <Text :max-lines="1" type="supporting">
              {{ file.meta }}
            </Text>
          </VStack>
        </Card>
      </Grid>
    </ScrollableArea>
  </Card>
</template>
```

### Two-axis board

axis both gives the board max-content inline sizing, so one viewport scrolls both ways.

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { HStack, VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const columns = [
  {
    label: 'Recently opened',
    files: [
      { id: 'brief', title: 'Q3 launch brief', meta: 'Edited 2 days ago' },
      { id: 'pricing', title: 'Pricing rework', meta: 'Edited 3 days ago' },
      { id: 'onboarding', title: 'Onboarding flow v4', meta: 'Edited 4 days ago' },
      { id: 'macros', title: 'Support macros', meta: 'Edited last week' },
    ],
  },
  {
    label: 'Shared with you',
    files: [
      { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
      { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
      { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
      { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
    ],
  },
  {
    label: 'Templates',
    files: [
      { id: 'weekly', title: 'Weekly report', meta: 'Used 24 times' },
      { id: 'checklist', title: 'Launch checklist', meta: 'Used 18 times' },
      { id: 'review', title: 'Design review', meta: 'Used 11 times' },
      { id: 'retro', title: 'Retro board', meta: 'Used 9 times' },
    ],
  },
  {
    label: 'Archived',
    files: [
      { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
      { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
      { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
      { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
    ],
  },
]
</script>

<template>
  <Card :padding="0" :width="420">
    <ScrollableArea axis="both" :height="300" label="Workspace board" :padding="4" role="region">
      <VStack :gap="4">
        <VStack :gap="0.5">
          <p class="layout-title layout-title--page">
            Workspace board
          </p>
          <Text type="supporting">
            16 files across 4 sections
          </Text>
        </VStack>
        <HStack :gap="3" align="start">
          <VStack v-for="column in columns" :key="column.label" :gap="2" :width="180">
            <Text color="secondary" type="label">
              {{ column.label }}
            </Text>
            <Card v-for="file in column.files" :key="file.id" :padding="3" variant="muted">
              <VStack :gap="1">
                <Text :max-lines="1" weight="medium">
                  {{ file.title }}
                </Text>
                <Text :max-lines="1" type="supporting">
                  {{ file.meta }}
                </Text>
              </VStack>
            </Card>
          </VStack>
        </HStack>
      </VStack>
    </ScrollableArea>
  </Card>
</template>
```

## Scrollable Area Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `axis` | `'inline' \| 'block' \| 'both'` | `'block'` | Logical axes that may scroll. The physical axis follows writing mode and direction. |
| `label` | `string` | - | Accessible name. Required. The viewport enters the tab order only while an axis overflows. |
| `role` | `'group' \| 'region'` | `'group'` | Role of the named viewport. |
| `overscroll` | `'allow' \| 'contain'` | `'allow'` | Whether a gesture at the edge continues into an ancestor. Containment applies only while that axis overflows. |
| `stickyContainment` | `'whenScrollable' \| 'always'` | `'whenScrollable'` | `whenScrollable` passes sticky ownership outward when the content fits. `always` keeps this viewport as the sticky boundary. |
| `padding` | `SpacingStep` | `0` | Content padding on every edge. |
| `paddingInline` | `SpacingStep` | - | Inline-axis content padding. Overrides `padding` on that axis. |
| `paddingInlineStart` | `SpacingStep` | - | Inline-start content padding. |
| `paddingInlineEnd` | `SpacingStep` | - | Inline-end content padding. |
| `paddingBlock` | `SpacingStep` | - | Block-axis content padding. Overrides `padding` on that axis. |
| `paddingBlockStart` | `SpacingStep` | - | Block-start content padding. |
| `paddingBlockEnd` | `SpacingStep` | - | Block-end content padding. |
| `isFullBleed` | `boolean` | `false` | Lets the viewport escape an ancestor's padding. Content padding stays as set. |
| `scrollbars` | `boolean` | `true` | Whether this area uses the custom scrollbar when a `ScrollbarsProvider` is above it. Set `false` to keep the native scrollbar. |
| `width` | `number \| string` | - | Viewport width. Numbers are pixels, strings are used as they are. |
| `height` | `number \| string` | - | Viewport height. Numbers are pixels, strings are used as they are. |
| `maxWidth` | `number \| string` | - | Maximum viewport width. |
| `minHeight` | `number \| string` | - | Minimum viewport height. |

`SpacingStep` accepts `0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10`.

## Slots

| Name | Description |
| --- | --- |
| `default` | Content inside the observed content box. Inline and both-axis modes size that box with `max-content` on the inline axis. |

## useScrollableArea

Import it from <code>@astryx-vue/core</code> when a component already owns the viewport and the content box. Options are <code>axis</code>, <code>label</code>, <code>role</code>, <code>overscroll</code>, and <code>stickyContainment</code>. It returns <code>viewport</code> (a callback ref), <code>content</code>, <code>viewportAttrs</code>, <code>viewportStyle</code>, and <code>state</code>.

Overflow uses a 1px tolerance. Before a measurement, a requested axis is <code>overflow: clip</code>. Afterward, an overflowing axis is <code>auto</code>.

## useScrollOverflow

<code>useScrollOverflow()</code> watches one horizontal scroller. Bind <code>scrollRef</code> to the element. <code>overflowStart</code>, <code>overflowEnd</code>, and <code>hasOverflow</code> are refs. The tolerance is 1px.

## Native attributes

`ScrollableArea` renders a `<div>`.

The following go to the viewport:

- HTML attributes.
- `aria-*` attributes.
- `data-*` attributes.
- `class`.
- `style`.

`class` is merged. The viewport also sets `data-scroll-axis` and, once measured, `data-scrollable-*` and `data-scroll-*-start` / `data-scroll-*-end`.
