Skip to content
Glint UI

ScrollArea

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

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.

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.

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.

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.

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.

API Reference

Properties

PropertyTypeDefaultDescription
viewport-heightin-out lengthno defaultTotal 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-widthin-out lengthno defaultTotal width of that content, measured and scrolled the same way.
viewport-yin-out lengthno defaultHow 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-xin-out lengthno defaultThe same, sideways: 0 at the leading edge, visible-width - viewport-width at the trailing one.
visible-heightout lengthno defaultThe window onto the content — what viewport-y and viewport-x slide.
visible-widthout lengthno default
scrollbar-hide-delayin duration0msHow 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-stepin length40pxHow 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-focusin boolfalseWhether 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-downout boolno defaultWhich 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-sidewaysout boolno default

Callbacks

CallbackDescription
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

FunctionDescription
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) -> lengthThe 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:

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.