# ScrollArea

A window onto content larger than itself, with a slim scrollbar drawn from tokens on each axis that overflows.

```slint
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Separator } from "@glint/components/separator.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 420px;
    height: 320px;
    background: Tokens.color-background;

    property <[string]> tags: [
        "v1.2.0-beta.31", "v1.2.0-beta.30", "v1.2.0-beta.29", "v1.2.0-beta.28",
        "v1.2.0-beta.27", "v1.2.0-beta.26", "v1.2.0-beta.25", "v1.2.0-beta.24",
        "v1.2.0-beta.23", "v1.2.0-beta.22", "v1.2.0-beta.21", "v1.2.0-beta.20",
    ];

    VerticalLayout {
        padding: 24px;
        alignment: center;

        Rectangle {
            height: 240px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            ScrollArea {
                accessible-role: region;
                accessible-label: "Tags";

                VerticalLayout {
                    padding: 12px;

                    for tag in root.tags: VerticalLayout {
                        Text {
                            height: 32px;
                            text: tag;
                            color: Tokens.color-foreground;
                            font-size: Tokens.typography-body-sm-size;
                            vertical-alignment: center;
                        }
                        Separator { }
                    }
                }
            }
        }
    }
}
```

## Usage

```slint
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component AppWindow inherits Window {
    width: 360px;
    height: 280px;
    background: Tokens.color-background;

    in property <[string]> lines;

    ScrollArea {
        accessible-role: region;
        accessible-label: "Log";

        VerticalLayout {
            padding: 12px;
            for line in root.lines: Text {
                height: 24px;
                text: line;
                color: Tokens.color-foreground;
            }
        }
    }
}
```

The content is measured for you as long as it is laid out — a `VerticalLayout` of rows, a `HorizontalLayout` of tiles — and both `viewport-height` and `viewport-width` follow it. Set them by hand only for content the layout cannot measure (a lone free-standing child), or to state a size larger than the content.

The two axes are independent: a bar appears only while its own axis overflows, and a surface whose content is wider than the window scrolls sideways under a horizontal wheel or its own bar along the bottom. The bars float over the content rather than taking a column or a row, so only the thumb takes the pointer, and only where it stands.

**The content itself never drags.** A press on the content belongs to whatever is under it — a row click, a text selection — and cannot fling the view out from under the finger. Scrolling is the wheel, the scrollbar thumb and the keyboard, and all three fire `scrolled`; an offset written from code does not, which is what lets a host tell the reader’s intent apart from its own corrections.

For a row model large enough that instantiating it would cost, reach for [`ListView`](/docs/components/data-table#listview) instead — the same surface under the one name the Slint compiler virtualizes.

## Examples

### Both axes

Each bar appears only while its own axis has somewhere to go, and where both run they stop short of each other’s corner.

```slint
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 420px;
    height: 300px;
    background: Tokens.color-background;

    VerticalLayout {
        padding: 24px;
        alignment: center;

        Rectangle {
            height: 220px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            ScrollArea {
                VerticalLayout {
                    padding: 12px;
                    spacing: 8px;

                    for row in 8: HorizontalLayout {
                        spacing: 8px;
                        for column in 8: Rectangle {
                            width: 88px;
                            height: 56px;
                            background: Tokens.color-surface-1;
                            border-radius: Tokens.radius-sm;
                            Text {
                                text: "R" + (row + 1) + "C" + (column + 1);
                                color: Tokens.color-muted-foreground;
                                font-size: Tokens.typography-body-sm-size;
                            }
                        }
                    }
                }
            }
        }
    }
}
```

### Scrollbars that fade out

`scrollbar-hide-delay` left at `0ms` — the default — is a bar that stays for as long as the content overflows. Anything else is a bar that wakes on a scroll and fades out once the reader has left it alone that long, giving the edge back to the content underneath: a bar nobody can see is a bar nobody can grab.

```slint
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 420px;
    height: 300px;
    background: Tokens.color-background;

    VerticalLayout {
        padding: 24px;
        spacing: 12px;
        alignment: center;

        Text {
            text: "Scroll the panel — the bar fades a second after you stop.";
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
            horizontal-alignment: center;
        }

        Rectangle {
            height: 190px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            ScrollArea {
                scrollbar-hide-delay: 1s;

                VerticalLayout {
                    padding: 12px;
                    for entry in 20: Text {
                        height: 28px;
                        text: "Entry " + (entry + 1);
                        color: Tokens.color-foreground;
                        font-size: Tokens.typography-body-sm-size;
                        vertical-alignment: center;
                    }
                }
            }
        }
    }
}
```

### A longer keyboard step

`keyboard-step` is how far one arrow key — or one accessible increment — moves the content. A page is a window less one of these, so the line the reader stopped on is still on screen after `Page Down`.

```slint
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 420px;
    height: 300px;
    background: Tokens.color-background;

    VerticalLayout {
        padding: 24px;
        spacing: 12px;
        alignment: center;

        Text {
            text: "Tab to the panel, then press an arrow key.";
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
            horizontal-alignment: center;
        }

        Rectangle {
            height: 190px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            ScrollArea {
                // One arrow key moves a whole card, not forty pixels.
                keyboard-step: 96px;

                VerticalLayout {
                    padding: 12px;
                    spacing: 8px;
                    for card in 10: Rectangle {
                        height: 88px;
                        background: Tokens.color-surface-1;
                        border-radius: Tokens.radius-sm;
                        Text {
                            text: "Card " + (card + 1);
                            color: Tokens.color-muted-foreground;
                        }
                    }
                }
            }
        }
    }
}
```

### Content that answers the keyboard

A surface with somewhere to go is a stop in the reading order, and Slint’s tab walk reaches an ancestor first — so where the content has tab stops of its own, that stop lands in front of them and `Tab` reaches an invisible scroller instead of the field. Set `content-takes-focus` there: the surface keeps the keys the content refuses and stops claiming a stop the content already owns.

```slint
import { Input } from "@glint/components/input.slint";
import { Label } from "@glint/components/label.slint";
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 420px;
    height: 300px;
    background: Tokens.color-background;

    property <[string]> fields: [
        "Full name", "Email", "Company", "Street", "City", "Postal code",
    ];

    VerticalLayout {
        padding: 24px;
        alignment: center;

        Rectangle {
            height: 220px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            ScrollArea {
                accessible-role: region;
                accessible-label: "Billing details";
                content-takes-focus: true;

                VerticalLayout {
                    padding: 16px;
                    spacing: 12px;

                    for field in root.fields: VerticalLayout {
                        spacing: 6px;
                        Label { text: field; }
                        Input { placeholder: field; }
                    }
                }
            }
        }
    }
}
```

### Revealing a band of content

`reveal(offset, extent)` scrolls just enough to bring a band — a row, measured in the content’s own coordinates — inside the window, and not a pixel further. Every Glint list that carries a keyboard highlight uses it when the highlight walks past an edge, and so can yours.

```slint
import { Button } from "@glint/components/button.slint";
import { ScrollArea } from "@glint/components/scroll-area.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 440px;
    height: 320px;
    background: Tokens.color-background;

    property <length> row-height: 32px;

    VerticalLayout {
        padding: 24px;
        spacing: 12px;
        alignment: center;

        HorizontalLayout {
            spacing: 8px;
            alignment: center;
            Button {
                text: "Reveal row 1";
                clicked => { area.reveal(0px, root.row-height); }
            }
            Button {
                text: "Reveal row 24";
                clicked => { area.reveal(23 * root.row-height, root.row-height); }
            }
        }

        Rectangle {
            height: 200px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            area := ScrollArea {
                VerticalLayout {
                    padding: 0px;
                    for row in 24: Text {
                        height: root.row-height;
                        text: "  Row " + (row + 1);
                        color: Tokens.color-foreground;
                        font-size: Tokens.typography-body-sm-size;
                        vertical-alignment: center;
                    }
                }
            }
        }
    }
}
```

## API Reference

### Properties

| Property               | Type            | Default    | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ---------------------- | --------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `viewport-height`      | `in-out length` | no default | Total height of the scrollable content, measured off the children's layout unless a call site states it. It scrolls once this exceeds `visible-height`. A `ListView` has the compiler write it instead, from the rows it has instantiated.                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `viewport-width`       | `in-out length` | no default | Total width of that content, measured and scrolled the same way.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `viewport-y`           | `in-out length` | no default | How far the content is scrolled, as a non-positive offset — 0 at the top, `visible-height - viewport-height` at the bottom. Two-way, so a host that owns a keyboard cursor can scroll it into view; the wheel and the scrollbar write it too.                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `viewport-x`           | `in-out length` | no default | The same, sideways: 0 at the leading edge, `visible-width - viewport-width` at the trailing one.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `visible-height`       | `out length`    | no default | The window onto the content — what `viewport-y` and `viewport-x` slide.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `visible-width`        | `out length`    | no default |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `scrollbar-hide-delay` | `in duration`   | `0ms`      | How long a bar stays after the reader stops scrolling. `0ms` — the default — is a bar that stays for as long as the content overflows; anything else is a bar that appears when the surface is scrolled and fades out once the reader has left it alone that long.                                                                                                                                                                                                                                                                                                                                                                                               |
| `keyboard-step`        | `in length`     | `40px`     | How far one arrow key, or one accessible increment, moves the content. A page is a window less one of these, so what was at the edge stays on screen and the reader keeps their place.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `content-takes-focus`  | `in bool`       | `false`    | Whether the content has tab stops of its own — a `Textarea`'s field, a list of rows, a menu. ADR-0032 gives this surface a stop in the reading order, and the scope that takes it wraps the content so keys the content refuses bubble out to it. But an ancestor is reached \*first\* by Slint's pre-order tab walk, so where the content is focusable that stop lands in front of it: Tab reached an invisible scroller instead of the field, no ring drew, and typed characters went nowhere until a second Tab. Set this where the content answers the keyboard; the surface keeps the keys it is handed and stops claiming a stop the content already owns. |
| `scrolls-down`         | `out bool`      | no default | Which axes have somewhere to go. A bar takes room from the other bar's track, so each also has to know about the other.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `scrolls-sideways`     | `out bool`      | no default |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |

### Callbacks

| Callback     | Description                                                                                                                                                                                             |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scrolled()` | Fires when the reader scrolls — by wheel, by thumb or from the keyboard — and never for an offset written from code, which is what lets a host tell the reader's intent apart from its own corrections. |

### Functions

| Function                                 | Description                                                                                                                                                                                                                                                                                                                                    |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reveal(offset: length, extent: length)` | Scroll just enough to bring the band from `offset` to `offset + extent` — a row, measured in the content's own coordinates — inside the window, and not a pixel further. Every Glint list that carries a keyboard highlight has the same job when the highlight walks past an edge, so the arithmetic lives here rather than three times over. |
| `bounded-y(to: length) -> length`        | The offsets the content's own ends allow, along each axis. Offsets run non-positive, so the far end is the floor and 0 is the start; content that fits has both at 0 and every walk below is a no-op.                                                                                                                                          |
| `bounded-x(to: length) -> length`        |                                                                                                                                                                                                                                                                                                                                                |

## Accessibility

- **A stop only when there is somewhere to go.** A surface whose content fits takes no tab stop at all, the way every Glint affordance with nothing to do leaves the tab order (ADR-0032). One that overflows is a single stop.
- **Naming it is yours, and it takes two properties** — see below.
- **Keyboard.** `Up` / `Down` walk the vertical axis and `Left` / `Right` the horizontal one, each by `keyboard-step`; `Page Up` / `Page Down` move a window less one step of overlap; `Home` and `End` reach the two ends of both axes. A surface that only overflows sideways still answers `Home` and `End`.
- **Keys bubble from the content.** The keyboard scope wraps the content rather than sitting beside it, so a key a focused control inside does not want — an arrow past the end of an `Input`’s text — reaches the surface that can use it, while one the control does want never does.
- **A walk that moves nothing says nothing.** An arrow held against the end of the content does not fire `scrolled`, so a host counting the reader’s scrolls never hears from a key that changed no offset.
- **Focus ring on the surface’s own edge.** A surface fills the slot it is given edge to edge, so the ring is drawn at offset `0` rather than outside the box, where an ancestor would clip it or it would land over the neighbors.
- **The surface adds no clip of its own.** The `Flickable` under it already clips to exactly this box, and a second clip would take a nested surface’s content out of the reading order — Slint lets `Tab` land on an off-screen item only while every clipper hiding it is a `Flickable` (ADR-0038).

### Naming a scrolling surface

The surface ships with no `accessible-role`, and Slint refuses an `accessible-label` without one — so a bare `accessible-label:` is a compile error rather than a silent no-op. Set both at the call site:

```slint
ScrollArea {
    accessible-role: region;
    accessible-label: "Transcript";
}
```

ADR-0032 decided it this way on purpose: putting an unnamed region node on every scrolling surface in the library would add noise to the accessibility tree without adding a name to read, and only the host knows what the region is. Where the content is a row model, reach for [`ListView`](/docs/components/data-table#listview) instead — the same surface with `accessible-role: list` already on it.
