Carousel
A snap-to-item scroller along a row or down a column, with optional looping, autoplay and controls of your own.
import { Carousel } from "@glint/components/carousel.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 300px; background: Tokens.color-background;
property <[string]> slides: ["1", "2", "3", "4", "5"];
VerticalLayout { padding: 24px; alignment: center;
Carousel { accessible-label: "Featured slides"; height: 220px; count: root.slides.length;
for slide in root.slides: Rectangle { background: Tokens.color-surface-1; border-radius: Tokens.radius-lg; Text { text: slide; color: Tokens.color-foreground; font-size: Tokens.typography-headline-size; font-weight: Tokens.typography-weight-semibold; } } } }}Usage
import { Carousel } from "@glint/components/carousel.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component AppWindow inherits Window { width: 520px; height: 300px; background: Tokens.color-background;
in property <[string]> slides;
VerticalLayout { padding: 24px;
c := Carousel { accessible-label: "Featured slides"; count: root.slides.length; changed(index) => { debug("now showing", index); }
for slide in root.slides: Rectangle { background: Tokens.color-surface-1; border-radius: Tokens.radius-lg; Text { text: slide; color: Tokens.color-foreground; } } }
// Indicators, driven by `current`. HorizontalLayout { alignment: center; spacing: 8px; for i in root.slides.length: Rectangle { width: 8px; height: 8px; border-radius: 4px; background: Tokens.color-primary; opacity: i == c.current ? 1.0 : 0.3; } } }}Slint has one @children slot and cannot count what lands in it, so count is
passed the way ScrollArea takes viewport-height. Every settled scroll
position lands on an item boundary, and current is the index that boundary
belongs to — two-way, so writing it steps the carousel and changed reports the
steps the user makes.
Items are laid out by the track, so they must be free to stretch: give an
item a fixed size and it stops lining up with the snap points. item-length
sets that size for all of them at once, and it defaults to the whole viewport —
one item at a time.
CarouselColumn is the same carousel stacked: it snaps on Y, answers
Up / Down and puts its controls above and below the items.
It is a component of its own rather than an orientation property because a
@children slot is written once and never inside an if — the axis is
structural, the way it is for ButtonGroupColumn, Sheet and Drawer
(ADR-0027). Everything below is true of both.
Examples
A column
import { CarouselColumn } from "@glint/components/carousel.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 420px; height: 340px; background: Tokens.color-background;
property <[string]> steps: ["Plan", "Build", "Review", "Ship"];
VerticalLayout { padding: 24px; alignment: center;
CarouselColumn { accessible-label: "Release steps"; width: 280px; height: 260px; count: root.steps.length;
for step in root.steps: Rectangle { background: Tokens.color-surface-1; border-radius: Tokens.radius-lg; Text { text: step; color: Tokens.color-foreground; font-size: Tokens.typography-title-size; font-weight: Tokens.typography-weight-semibold; } } } }}Peeking neighbors
Shrink item-length below the viewport and the neighboring items show at the
edges. align decides where the current item comes to rest:
CarouselAlign.start leaves it flush with the leading edge, and
CarouselAlign.center centers it with its neighbors peeking in on both sides —
the carousel reserves half a viewport at each end so the first and last items
can reach the middle. The built-in controls sit against the carousel’s own
edges, which is where the peeking items are, so this one steps by drag and by
keyboard instead.
import { Carousel, CarouselAlign } from "@glint/components/carousel.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 300px; background: Tokens.color-background;
property <[string]> covers: ["A", "B", "C", "D", "E", "F"];
VerticalLayout { padding: 24px; alignment: center;
Carousel { accessible-label: "Covers"; height: 200px; count: root.covers.length; align: CarouselAlign.center; item-length: 180px; spacing: 12px; controls: false;
for cover in root.covers: Rectangle { background: Tokens.color-surface-1; border-radius: Tokens.radius-lg; Text { text: cover; color: Tokens.color-foreground; font-size: Tokens.typography-h1-size; font-weight: Tokens.typography-weight-semibold; } } } }}Looping and autoplay
loop wraps the ends into each other, so stepping past the last item lands on
the first and the controls never run out. autoplay-interval steps the carousel
on its own — it waits while the reader is on the track or a drag is under way,
picks up again once they leave, and starts its wait over whenever anything else
moves the carousel. A carousel with nowhere left to go stops rather than sitting
on the last item pushing against the end.
import { Carousel } from "@glint/components/carousel.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 300px; background: Tokens.color-background;
property <[string]> quotes: [ "Ships on Tuesday", "Two compilers", "One artifact", "No shims", ];
VerticalLayout { padding: 24px; alignment: center;
Carousel { accessible-label: "Quotes"; height: 200px; count: root.quotes.length; loop: true; autoplay-interval: 2s;
for quote in root.quotes: Rectangle { background: Tokens.color-surface-1; border-radius: Tokens.radius-lg; Text { text: quote; color: Tokens.color-foreground; font-size: Tokens.typography-title-size; } } } }}Controls of your own
controls: false takes the built-in prev/next buttons off. can-previous and
can-next say whether stepping in each direction would land on another item —
a looping carousel can always step, as long as there is a second item to step to
— and next(), previous() and go(index) are the doors your own controls
knock on. status-label is what the track announces politely on every index
change; override it to translate or to say something else.
import { Button, ButtonSize, ButtonVariant } from "@glint/components/button.slint";import { Carousel } from "@glint/components/carousel.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 560px; height: 340px; background: Tokens.color-background;
property <[string]> slides: ["One", "Two", "Three", "Four"];
VerticalLayout { padding: 24px; spacing: 16px; alignment: center;
c := Carousel { accessible-label: "Onboarding"; height: 180px; count: root.slides.length; controls: false;
for slide in root.slides: Rectangle { background: Tokens.color-surface-1; border-radius: Tokens.radius-lg; Text { text: slide; color: Tokens.color-foreground; font-size: Tokens.typography-title-size; } } }
HorizontalLayout { alignment: center; spacing: 12px;
Button { variant: ButtonVariant.outline; size: ButtonSize.icon-sm; leading-icon: IconSet.ChevronLeft; accessible-label: "Previous slide"; disabled: !c.can-previous; clicked => { c.previous(); } }
for i in root.slides.length: Rectangle { width: 10px; height: 10px; y: (parent.height - self.height) / 2; border-radius: 5px; background: Tokens.color-primary; opacity: i == c.current ? 1.0 : 0.3; }
Button { variant: ButtonVariant.outline; size: ButtonSize.icon-sm; leading-icon: IconSet.ChevronRight; accessible-label: "Next slide"; disabled: !c.can-next; clicked => { c.next(); } } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
count | in int | 0 | Number of items handed to the slot — usually model.length. |
item-length | in length | no default | Length of a single item along the track; the whole viewport (one item at a time) by default, which each track sets on its own axis. |
spacing | in length | 16px | Gap between two items. |
current | in-out int | 0 | Two-way; index of the item the track is snapped to. |
controls | in bool | true | Show the built-in prev/next controls. |
loop | in bool | false | Wrap the ends into each other: stepping past the last item lands on the first, and the controls never run out. |
align | in CarouselAlign | CarouselAlign.start | Where the current item rests in the viewport. |
autoplay-interval | in duration | 0ms | Step the carousel by itself, this often. 0ms — the default — is a carousel that only ever moves when it is asked to; anything else waits while the reader is on the track and picks up again once they leave. |
previous-label | in string | @tr("Previous item") | Accessible name of the control that steps backwards. |
next-label | in string | @tr("Next item") | Accessible name of the control that steps forwards. |
status-label | in string | @tr("Item {} of {}", root.current + 1, root.count) | Announced politely whenever current changes; override to translate. |
can-previous | out bool | no default | Whether stepping in each direction would land on another item — for consumer-drawn controls (controls: false). A looping carousel can always step, as long as there is a second item to step to. |
can-next | out bool | no default | |
track-window | in length | no default | The window onto the track, measured along the axis it runs. |
track-offset | in length | no default | Where the track stands right now — non-positive, 0 at the first item. |
track-hovered | in bool | false | Set by the track while the pointer is on it. |
engaged | out bool | no default | True while the reader is on the carousel — the pointer on the track, or a drag under way. Autoplay waits for as long as it holds. |
stride | out length | no default | Distance between two item origins. |
track-length | out length | no default | The whole track: every item, and the gaps between them. |
viewport-length | out length | no default | What the track's Flickable scrolls: the items, plus the room a centred carousel needs at either end. A Flickable never scrolls its content past its own edges, so the empty half-viewport beside the first and last items has to be part of the content — the padding the tracks below put at both ends of their layout. |
inset | out length | no default | How much room that is: half a viewport minus half an item, so the current item lands in the middle with its neighbours peeking in. |
dragging | out bool | no default | True while the user's own drag (or its inertia) drives the track, so the content follows the finger instead of easing behind it. |
track-focus-visible | in bool | false | Set by the track from its own focus scope: the ring is drawn here so both tracks draw the same one. |
Callbacks
| Callback | Description |
|---|---|
changed(int) | Fired with the new index whenever the carousel settles on another item. |
track-rest(length) | Asks the track to come to rest at this offset. Only the track can move itself — the Flickable is its own — so every snap goes out this way. |
Functions
| Function | Description |
|---|---|
go(index: int) | Snap onto index, wrapped or clamped to the items that exist. Reports through changed only when the index actually moves. |
next() | |
previous() | |
track-ends-key(text: string) -> bool | The two keys both axes answer the same way: whichever arrows a track binds, Home and End belong to the base. True when the key was one of them, which is what the track returns accept for. |
track-dragged() | The track reports its own gesture: a drag streams in continuously, and the settle timer fires once the stream stops, which is when the gesture (and its inertia) is over. |
Enums
| Enum | Values |
|---|---|
CarouselAlign | start, center |
CarouselColumn
The same carousel stacked: ↑ / ↓ step it, and the
controls sit against the top and bottom edges. Everything but the track and
the two keys it answers is shared with Carousel, which is why the two tables
are the same table twice.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
count | in int | 0 | Number of items handed to the slot — usually model.length. |
item-length | in length | no default | Length of a single item along the track; the whole viewport (one item at a time) by default, which each track sets on its own axis. |
spacing | in length | 16px | Gap between two items. |
current | in-out int | 0 | Two-way; index of the item the track is snapped to. |
controls | in bool | true | Show the built-in prev/next controls. |
loop | in bool | false | Wrap the ends into each other: stepping past the last item lands on the first, and the controls never run out. |
align | in CarouselAlign | CarouselAlign.start | Where the current item rests in the viewport. |
autoplay-interval | in duration | 0ms | Step the carousel by itself, this often. 0ms — the default — is a carousel that only ever moves when it is asked to; anything else waits while the reader is on the track and picks up again once they leave. |
previous-label | in string | @tr("Previous item") | Accessible name of the control that steps backwards. |
next-label | in string | @tr("Next item") | Accessible name of the control that steps forwards. |
status-label | in string | @tr("Item {} of {}", root.current + 1, root.count) | Announced politely whenever current changes; override to translate. |
can-previous | out bool | no default | Whether stepping in each direction would land on another item — for consumer-drawn controls (controls: false). A looping carousel can always step, as long as there is a second item to step to. |
can-next | out bool | no default | |
track-window | in length | no default | The window onto the track, measured along the axis it runs. |
track-offset | in length | no default | Where the track stands right now — non-positive, 0 at the first item. |
track-hovered | in bool | false | Set by the track while the pointer is on it. |
engaged | out bool | no default | True while the reader is on the carousel — the pointer on the track, or a drag under way. Autoplay waits for as long as it holds. |
stride | out length | no default | Distance between two item origins. |
track-length | out length | no default | The whole track: every item, and the gaps between them. |
viewport-length | out length | no default | What the track's Flickable scrolls: the items, plus the room a centred carousel needs at either end. A Flickable never scrolls its content past its own edges, so the empty half-viewport beside the first and last items has to be part of the content — the padding the tracks below put at both ends of their layout. |
inset | out length | no default | How much room that is: half a viewport minus half an item, so the current item lands in the middle with its neighbours peeking in. |
dragging | out bool | no default | True while the user's own drag (or its inertia) drives the track, so the content follows the finger instead of easing behind it. |
track-focus-visible | in bool | false | Set by the track from its own focus scope: the ring is drawn here so both tracks draw the same one. |
Callbacks
| Callback | Description |
|---|---|
changed(int) | Fired with the new index whenever the carousel settles on another item. |
track-rest(length) | Asks the track to come to rest at this offset. Only the track can move itself — the Flickable is its own — so every snap goes out this way. |
Functions
| Function | Description |
|---|---|
go(index: int) | Snap onto index, wrapped or clamped to the items that exist. Reports through changed only when the index actually moves. |
next() | |
previous() | |
track-ends-key(text: string) -> bool | The two keys both axes answer the same way: whichever arrows a track binds, Home and End belong to the base. True when the key was one of them, which is what the track returns accept for. |
track-dragged() | The track reports its own gesture: a drag streams in continuously, and the settle timer fires once the stream stops, which is when the gesture (and its inertia) is over. |
Enums
| Enum | Values |
|---|---|
CarouselAlign | start, center |
The rows named track-* are not for you. Both components are one brain and two
tracks: CarouselBase holds the index and the snap arithmetic, and each of
these tells it how wide its window is and where it stands. Slint has no
protected, so a member a base publishes for its own subclass is published to
the call site as well — and the call site’s binding wins. track-window: 100px
compiles and lies to the arithmetic. The prefix is the whole guard rail
(ADR-0043): it cannot stop you, but nothing reaches into the machinery by
accident.
Accessibility
- A labeled region. The carousel is one stop in the reading order carrying
accessible-role: region; overrideaccessible-labelto name it, because “Carousel” tells a reader which widget it is and not what is in it. - The track is a live list. It wears
accessible-role: listwith the item count and the axis it runs along, and a polite live region that announcesstatus-label— “Item 3 of 5” by default — on every index change, without interrupting whatever the reader is doing. - The controls are named and guarded.
previous-labelandnext-labelname the built-in buttons, which report as disabled at the ends of a carousel that does not loop and carry a default action so assistive technology never needs the pointer. - Keyboard. Tab focuses the carousel; the arrows along its axis
step — Left / Right for
Carousel, Up / Down forCarouselColumn— and Home / End jump to the ends. The focus scope wraps the built-in controls rather than sitting beside them, so Tab moves from the carousel onto its own buttons and the arrows keep stepping from there. - Autoplay waits for the reader. The pointer resting on the track, or a drag under way, holds the timer — so a carousel does not move out from under someone reading it — and any deliberate step restarts the wait rather than stepping again a moment later.
- A single item scrolls nowhere. The track stops being interactive below two items, so there is no gesture that pretends to have somewhere to go.