Scrollable

A comprehensive scrollable container component that provides custom scrollbars, scroll tracking, and virtualization capabilities. Supports both vertical and horizontal scrolling with customizable appearance and behavior.

Install

#Import

use gpui_kit::component::{
    scroll::{ScrollableElement, ScrollbarAxis, ScrollbarMode},
    StyledExt as _,
};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ScrollAxis, Scrollable } from "scrollable.slint";
use iced::widget::{column, container, mouse_area, row, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};

#Usage

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The three axes in one box: a column of rows taller than a fixed box, a row
// of cards wider than one, and a grid that overflows both ways. The vertical
// area reports its viewport, which is the offset line below the box.
let usage = column![
    row![
        sample(
            "Vertical",
            Scrollable::new(rows(1, 20))
                .width(Length::Fixed(220.0))
                .height(Length::Fixed(180.0))
                .on_scroll(|viewport| {
                    let offset = viewport.absolute_offset();
                    Message::UsageViewport(offset.x, offset.y)
                }),
        ),
        sample(
            "Horizontal",
            Scrollable::new(cards(10))
                .axis(Axis::Horizontal)
                .width(Length::Fixed(220.0))
                .height(Length::Fixed(120.0)),
        ),
        sample(
            "Both",
            Scrollable::new(grid(8, 8))
                .axis(Axis::Both)
                .width(Length::Fixed(260.0))
                .height(Length::Fixed(180.0)),
        ),
    ]
    .spacing(theme::SPACING_LG),
    text(format!(
        "vertical area at x {:.0}, y {:.0}",
        app.usage_x, app.usage_y
    ))
    .size(theme::FONT_SIZE_XS)
    .style(|theme: &Theme| text::Style {
        color: Some(Tokens::of(theme).muted_foreground),
    }),
]
.spacing(theme::SPACING_MD);

#Basic Scrollable Container

The simplest way to make any element scrollable is using the overflow_scrollbar() method from ScrollableElement trait.

This method is almost like the overflow_scroll() method, but it adds scrollbars.

  • overflow_scrollbar() - Adds scrollbars for both axes as needed.
  • overflow_x_scrollbar() - Adds horizontal scrollbar as needed.
  • overflow_y_scrollbar() - Adds vertical scrollbar as needed.
use gpui_kit::{div, Axis};
use gpui_kit::component::ScrollableElement;

div()
    .id("scrollable-container")
    .size_full()
    .child("Your content here")
    .overflow_scrollbar()
Scrollable {
    height: 180px;
    VerticalLayout {
        padding: 8px;
        spacing: 8px;
        for i in 20: Text {
            text: "Row \{i + 1}";
            color: Theme.foreground;
        }
    }
}

#Vertical Scrolling

v_flex()
    .id("scrollable-container")
    .overflow_y_scrollbar()
    .gap_2()
    .p_4()
    .child("Scrollable Content")
    .children((0..100).map(|i| {
        div()
            .h(px(40.))
            .w_full()
            .bg(cx.theme().secondary)
            .child(format!("Item {}", i))
    }))
Scrollable {
    height: 180px;
    VerticalLayout {
        padding: 12px;
        spacing: 10px;
        for i in 12: Rectangle {
            height: 44px;
            border-radius: Theme.radius-md;
            border-width: 1px;
            border-color: Theme.border;
            background: Theme.muted.with-alpha(0.35);
            HorizontalLayout {
                padding-left: 12px;
                padding-right: 12px;
                alignment: center;
                Text {
                    text: "Commit \{i + 1}";
                    color: Theme.foreground;
                    font-size: Theme.font-size-sm;
                    vertical-alignment: center;
                }
            }
        }
    }
}

#Horizontal Scrolling

h_flex()
    .id("scrollable-container")
    .overflow_x_scrollbar()
    .gap_2()
    .p_4()
    .children((0..50).map(|i| {
        div()
            .min_w(px(120.))
            .h(px(80.))
            .bg(cx.theme().accent)
            .child(format!("Card {}", i))
    }))
Scrollable {
    axis: ScrollAxis.horizontal;
    height: 120px;
    HorizontalLayout {
        padding: 8px;
        spacing: 8px;
        for i in 10: Rectangle {
            width: 120px;
            border-radius: Theme.radius-md;
            border-width: 1px;
            border-color: Theme.border;
            background: Theme.muted.with-alpha(0.35);
            Text {
                text: "Card \{i + 1}";
                color: Theme.foreground;
                font-size: Theme.font-size-sm;
                horizontal-alignment: center;
                vertical-alignment: center;
            }
        }
    }
}

#Both Directions

div()
    .id("scrollable-container")
    .size_full()
    .overflow_scrollbar()
    .child(
        div()
            .w(px(2000.))  // Wide content
            .h(px(2000.))  // Tall content
            .bg(cx.theme().background)
            .child("Large content area")
    )
Scrollable {
    axis: ScrollAxis.both;
    height: 200px;
    GridLayout {
        padding: 8px;
        spacing: 8px;
        // One loop with the row worked out from the index: a nested
        // `for` needs its own braces and reads worse here.
        for i in 64: Rectangle {
            row: (i - mod(i, 8)) / 8;
            col: mod(i, 8);
            width: 96px;
            height: 56px;
            border-radius: Theme.radius-md;
            border-width: 1px;
            border-color: Theme.border;
            background: Theme.muted.with-alpha(0.35);
            Text {
                text: "\{(i - mod(i, 8)) / 8 + 1},\{mod(i, 8) + 1}";
                color: Theme.muted-foreground;
                font-size: Theme.font-size-xs;
                horizontal-alignment: center;
                vertical-alignment: center;
            }
        }
    }
}

#File Browser with Scrolling

pub struct FileBrowser {
    files: Vec<String>,
}

impl Render for FileBrowser {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .border_1()
            .border_color(cx.theme().border)
            .size_full()
            .child(
                v_flex()
                    .gap_1()
                    .p_2()
                    .overflow_y_scrollbar()
                    .children(self.files.iter().map(|file| {
                        div()
                            .h(px(32.))
                            .w_full()
                            .px_2()
                            .flex()
                            .items_center()
                            .hover(|style| style.bg(cx.theme().secondary_hover))
                            .child(file.clone())
                    }))
            )
    }
}
SlintNo Slint version of this example yet.

#Chat Messages with Auto-scroll

pub struct ChatView {
    messages: Vec<String>,
    scroll_handle: ScrollHandle,
    should_auto_scroll: bool,
}

impl ChatView {
    fn add_message(&mut self, message: String) {
        self.messages.push(message);

        if self.should_auto_scroll {
            // Scroll to bottom for new messages
            let max_offset = self.scroll_handle.max_offset();
            self.scroll_handle.set_offset(point(px(0.), max_offset.y));
        }
    }
}
SlintNo Slint version of this example yet.

#Data Table with Virtual Scrolling

pub struct DataTable {
    data: Vec<Vec<String>>,
    scroll_handle: VirtualListScrollHandle,
}

impl Render for DataTable {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        VirtualList::new(
            self.scroll_handle.clone(),
            self.data.len(),
            |_ix, _window, _cx| size(px(800.), px(32.)), // Fixed row height
            |ix, bounds, _selected, _window, cx| {
                h_flex()
                    .size(bounds.size)
                    .border_b_1()
                    .border_color(cx.theme().border)
                    .children(self.data[ix].iter().map(|cell| {
                        div()
                            .flex_1()
                            .px_2()
                            .flex()
                            .items_center()
                            .child(cell.clone())
                    }))
                    .into_any_element()
            },
        )
    }
}
SlintNo Slint version of this example yet.

#Custom Scrollbars

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The bar itself, where iced allows it to be arranged: a thicker bar, a rail
// embedded with a gap between it and the content, and a thumb drawn from a
// different token. There is no manual scrollbar widget to attach the way GPUI
// has one — iced's region owns its bar — so this is the whole custom surface.
let custom = row![
    sample(
        "Thicker bar",
        Scrollable::new(rows(1, 20))
            .scrollbar_width(18.0)
            .scroller_width(12.0)
            .width(Length::Fixed(200.0))
            .height(Length::Fixed(160.0)),
    ),
    sample(
        "Embedded track",
        Scrollable::new(rows(1, 20))
            .scrollbar_spacing(6.0)
            .track(Ink::Border)
            .thumb(Ink::Primary)
            .width(Length::Fixed(200.0))
            .height(Length::Fixed(160.0)),
    ),
    sample(
        "Foreground thumb",
        Scrollable::new(rows(1, 20))
            .thumb(Ink::Foreground)
            .width(Length::Fixed(200.0))
            .height(Length::Fixed(160.0)),
    ),
]
.spacing(theme::SPACING_LG);

#Manual Scrollbar Creation

For more control, you can create scrollbars manually:

use gpui_kit::component::scroll::{ScrollableElement};

pub struct ScrollableView {
    scroll_handle: ScrollHandle,
}

impl Render for ScrollableView {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .relative()
            .size_full()
            .child(
                div()
                    .id("content")
                    .track_scroll(&self.scroll_handle)
                    .overflow_scroll()
                    .size_full()
                    .child("Your scrollable content")
            )
            .vertical_scrollbar(&self.scroll_handle)
    }
}
SlintNo Slint version of this example yet.

#Virtualization

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// iced 0.14 has no windowed list, so every one of these rows is a real
// element that is built and drawn; nothing here is virtual. What the
// component does expose is the viewport, and `on_scroll` reads the visible
// range out of it. A caller that needs windowing builds it on that number —
// this port does not fake an API for it.
let first = (app.window_offset / ROW_PITCH).floor().max(0.0) as usize + 1;
let visible = (WINDOW_HEIGHT / ROW_PITCH).ceil() as usize;
let last = (first + visible - 1).min(WINDOW_ROWS);
let virtualization = column![
    text(format!(
        "rows {first}–{last} of {WINDOW_ROWS} are on screen; all {WINDOW_ROWS} are built"
    ))
    .size(theme::FONT_SIZE_XS)
    .style(|theme: &Theme| text::Style {
        color: Some(Tokens::of(theme).muted_foreground),
    }),
    Scrollable::new(rows(1, WINDOW_ROWS))
        .height(Length::Fixed(WINDOW_HEIGHT))
        .on_scroll(|viewport| Message::WindowViewport(viewport.absolute_offset().y)),
]
.spacing(theme::SPACING_SM);

#VirtualList for Large Datasets

For rendering large lists efficiently, use VirtualList:

use gpui_kit::component::{VirtualList, VirtualListScrollHandle};

pub struct LargeListView {
    items: Vec<String>,
    scroll_handle: VirtualListScrollHandle,
}

impl Render for LargeListView {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let item_count = self.items.len();

        VirtualList::new(
            self.scroll_handle.clone(),
            item_count,
            |ix, window, cx| {
                // Item sizes - can be different for each item
                size(px(300.), px(40.))
            },
            |ix, bounds, selected, window, cx| {
                // Render each item
                div()
                    .size(bounds.size)
                    .bg(if selected {
                        cx.theme().accent
                    } else {
                        cx.theme().background
                    })
                    .child(format!("Item {}: {}", ix, self.items[ix]))
                    .into_any_element()
            },
        )
    }
}
SlintNo Slint version of this example yet.

#Scrolling to Specific Items

impl LargeListView {
    fn scroll_to_item(&mut self, index: usize) {
        self.scroll_handle.scroll_to_item(index, ScrollStrategy::Top);
    }

    fn scroll_to_item_centered(&mut self, index: usize) {
        self.scroll_handle.scroll_to_item(index, ScrollStrategy::Center);
    }
}
SlintNo Slint version of this example yet.

#Variable Item Sizes

VirtualList::new(
    scroll_handle,
    items.len(),
    |ix, window, cx| {
        // Different heights based on content
        let height = if items[ix].len() > 50 {
            px(80.)  // Tall items for long content
        } else {
            px(40.)  // Normal height
        };
        size(px(300.), height)
    },
    |ix, bounds, selected, window, cx| {
        // Render logic
    },
)
SlintNo Slint version of this example yet.

#Theme Customization

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The bar reads the theme's tokens when it is drawn, so these are choices of
// *which* token, not of colour: a track from the border, a thumb from the
// foreground, a thumb from the primary, and the two show modes iced can
// express. iced has no per-component theme JSON to write, which is what the
// GPUI page configures instead.
let themed = column![
    row![
        sample(
            "Muted thumb",
            Scrollable::new(rows(1, 20))
                .width(Length::Fixed(200.0))
                .height(Length::Fixed(160.0)),
        ),
        sample(
            "Track and foreground",
            Scrollable::new(rows(1, 20))
                .track(Ink::Border)
                .thumb(Ink::Foreground)
                .width(Length::Fixed(200.0))
                .height(Length::Fixed(160.0)),
        ),
        sample(
            "Primary thumb",
            Scrollable::new(rows(1, 20))
                .thumb(Ink::Primary)
                .width(Length::Fixed(200.0))
                .height(Length::Fixed(160.0)),
        ),
    ]
    .spacing(theme::SPACING_LG),
    row![
        sample(
            "On hover",
            Scrollable::new(rows(1, 20))
                .visibility(Visibility::Hover)
                .width(Length::Fixed(200.0))
                .height(Length::Fixed(160.0)),
        ),
        sample(
            "Hidden",
            Scrollable::new(rows(1, 20))
                .visibility(Visibility::Hidden)
                .width(Length::Fixed(200.0))
                .height(Length::Fixed(160.0)),
        ),
    ]
    .spacing(theme::SPACING_LG),
]
.spacing(theme::SPACING_MD);

#Scrollbar Appearance

Customize scrollbar appearance through theme configuration:

// In your theme JSON
{
    "scrollbar.background": "#ffffff20",
    "scrollbar.thumb.background": "#00000060",
    "scrollbar.thumb.hover.background": "#000000"
}
SlintNo Slint version of this example yet.

#Scrollbar Show Modes

Control when scrollbars are visible:

use gpui_kit::component::{Theme, scroll::ScrollbarMode};

// In theme initialization
Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx); // Only while scrolling
Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);     // On hover
Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);    // Always visible
SlintNo Slint version of this example yet.

#System Integration

Sync scrollbar behavior with system preferences:

// Automatically sync with system settings
Theme::sync_scrollbar_appearance(cx);
SlintNo Slint version of this example yet.