ScrollArea
A window onto content larger than itself, with a slim scrollbar drawn from tokens on each axis that overflows.
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
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 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.
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.
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.
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.
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.
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
0rather 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
Flickableunder 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 aFlickable(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:
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 instead — the same surface
with accessible-role: list already on it.