gpuicn Components

Component 08 of 60

Carousel

Cycles through content with buttons, pagination, keyboard controls, and pointer gestures.

site/src/previews/carousel.rs

Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.

Carousel, running in the browser. The second tab shows the preview's own source, which includes its layout and demo state. For the smallest working example, see Usage below. Download the whole showcase for its imports and helpers.

Installation

After the one-time setup, run the command from your app root. The CLI writes the modules and keeps src/ui/mod.rs in step.

gpuicn add carousel

If you prefer to copy the files by hand, declare them yourself:

// src/ui/mod.rs
pub mod theme;
pub mod carousel;

// src/main.rs
mod ui;

Usage

Put this in a view module and return example(window, cx) from its render method.

use crate::ui::carousel::*;
use gpui_kit::{App, IntoElement, ParentElement, Styled, Window, px};

fn example(window: &mut Window, cx: &mut App) -> impl IntoElement {
    let state = window.use_keyed_state("featured", cx, |_, _| {
        CarouselState::new(3).with_looping(true)
    });
    let slides = ["Build native", "Keep the source", "Share one theme"];

    Carousel::new("featured", &state)
        .aria_label("gpuicn highlights")
        .w(px(320.))
        .child(CarouselContent::new(&state).h(px(180.)).children(
            slides.into_iter().enumerate().map(|(index, label)| {
                CarouselItem::new(("featured.slide", index), index, &state).child(
                    gpui_kit::div()
                        .size_full()
                        .flex()
                        .items_center()
                        .justify_center()
                        .child(label),
                )
            }),
        ))
        .child(CarouselPrevious::new(&state))
        .child(CarouselNext::new(&state))
        .child(CarouselPagination::new().children(
            (0..3).map(|index| {
                CarouselPaginationItem::new(("featured.page", index), index, &state)
            }),
        ))
}

API reference

pub fn new(id: impl Into<ElementId>, state: &Entity<CarouselState>) -> Self

Creates a carousel bound to retained caller-owned state.

pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self

Sets the name announced for the carousel region.

pub fn new(state: &Entity<CarouselState>) -> Self

Creates a previous-slide control bound to the carousel state.

pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self

Sets the accessible control name.

pub fn new(state: &Entity<CarouselState>) -> Self

Creates a next-slide control bound to the carousel state.

pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self

Sets the accessible control name.

pub fn new(id: impl Into<ElementId>, index: usize, state: &Entity<CarouselState>) -> Self

Creates a pagination control for the zero-based item index.

pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self

Replaces the generated “Go to slide N” name.

Installed source, 236 lines
//! Nova-styled composition around GPUI Kit's native carousel engine.

use super::theme::{UiTheme, apply_style};
use gpui_icons::{LucideIcon, lucide};
use gpui_kit::base::AxisExt as _;
use gpui_kit::component::carousel::{
    Carousel as KitCarousel, CarouselNext as KitCarouselNext,
    CarouselPaginationItem as KitCarouselPaginationItem, CarouselPrevious as KitCarouselPrevious,
};
use gpui_kit::{
    AnyElement, App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
    StyleRefinement, Styled, Window, prelude::FluentBuilder as _,
};

pub use gpui_kit::component::carousel::{
    CarouselContent, CarouselEvent, CarouselItem, CarouselPagination, CarouselState,
};

/// A themed carousel region backed by GPUI Kit's snapping and gesture engine.
#[derive(IntoElement)]
pub struct Carousel {
    id: ElementId,
    state: Entity<CarouselState>,
    label: SharedString,
    style: StyleRefinement,
    children: Vec<AnyElement>,
}

impl Carousel {
    /// Creates a carousel bound to retained caller-owned state.
    pub fn new(id: impl Into<ElementId>, state: &Entity<CarouselState>) -> Self {
        Self {
            id: id.into(),
            state: state.clone(),
            label: "Carousel".into(),
            style: StyleRefinement::default(),
            children: Vec::new(),
        }
    }

    /// Sets the name announced for the carousel region.
    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = label.into();
        self
    }
}

impl ParentElement for Carousel {
    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
        self.children.extend(children);
    }
}

impl Styled for Carousel {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for Carousel {
    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = UiTheme::read(cx);
        let root = KitCarousel::new(self.id, &self.state)
            .accessibility_label(self.label)
            .gap(theme.space(3.))
            .font_family(theme.fonts.body.clone())
            .text_color(theme.colors.foreground)
            .children(self.children);
        apply_style(root, &self.style)
    }
}

/// Nova-styled previous-slide control.
#[derive(IntoElement)]
pub struct CarouselPrevious {
    state: Entity<CarouselState>,
    label: SharedString,
    style: StyleRefinement,
}

impl CarouselPrevious {
    /// Creates a previous-slide control bound to the carousel state.
    pub fn new(state: &Entity<CarouselState>) -> Self {
        Self {
            state: state.clone(),
            label: "Previous slide".into(),
            style: StyleRefinement::default(),
        }
    }

    /// Sets the accessible control name.
    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = label.into();
        self
    }
}

impl Styled for CarouselPrevious {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for CarouselPrevious {
    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = UiTheme::read(cx);
        let state = self.state;
        let snapshot = state.read(cx);
        let axis = snapshot.axis();
        let icon = if axis.is_horizontal() {
            LucideIcon::ChevronLeft
        } else {
            LucideIcon::ChevronUp
        };
        let control = KitCarouselPrevious::new(&state)
            .accessibility_label(self.label)
            .child(
                lucide(icon)
                    .size(theme.space(4.))
                    .text_color(theme.colors.foreground),
            );
        apply_style(control, &self.style)
    }
}

/// Nova-styled next-slide control.
#[derive(IntoElement)]
pub struct CarouselNext {
    state: Entity<CarouselState>,
    label: SharedString,
    style: StyleRefinement,
}

impl CarouselNext {
    /// Creates a next-slide control bound to the carousel state.
    pub fn new(state: &Entity<CarouselState>) -> Self {
        Self {
            state: state.clone(),
            label: "Next slide".into(),
            style: StyleRefinement::default(),
        }
    }

    /// Sets the accessible control name.
    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = label.into();
        self
    }
}

impl Styled for CarouselNext {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for CarouselNext {
    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = UiTheme::read(cx);
        let state = self.state;
        let snapshot = state.read(cx);
        let axis = snapshot.axis();
        let icon = if axis.is_horizontal() {
            LucideIcon::ChevronRight
        } else {
            LucideIcon::ChevronDown
        };
        let control = KitCarouselNext::new(&state)
            .accessibility_label(self.label)
            .child(
                lucide(icon)
                    .size(theme.space(4.))
                    .text_color(theme.colors.foreground),
            );
        apply_style(control, &self.style)
    }
}

/// Nova dot control for one carousel item.
#[derive(IntoElement)]
pub struct CarouselPaginationItem {
    id: ElementId,
    index: usize,
    state: Entity<CarouselState>,
    label: Option<SharedString>,
    style: StyleRefinement,
}

impl CarouselPaginationItem {
    /// Creates a pagination control for the zero-based item index.
    pub fn new(id: impl Into<ElementId>, index: usize, state: &Entity<CarouselState>) -> Self {
        Self {
            id: id.into(),
            index,
            state: state.clone(),
            label: None,
            style: StyleRefinement::default(),
        }
    }

    /// Replaces the generated “Go to slide N” name.
    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = Some(label.into());
        self
    }
}

impl Styled for CarouselPaginationItem {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for CarouselPaginationItem {
    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = UiTheme::read(cx);
        let state = self.state;
        let snapshot = state.read(cx);
        let selected = snapshot.selected_index() == Some(self.index);
        let index = self.index;
        let dot = gpui_kit::div()
            .size(theme.space(2.))
            .rounded(theme.radius.lg)
            .bg(if selected {
                theme.colors.primary
            } else {
                theme.colors.muted_foreground.opacity(0.45)
            });
        let control = KitCarouselPaginationItem::new(self.id, index, &state)
            .when_some(self.label, |control, label| {
                control.accessibility_label(label)
            });
        apply_style(control.child(dot), &self.style)
    }
}

Platform notes

Visual reference: shadcn/ui 4.19.0, Neutral Nova. Behavior comes from GPUI Kit 0.6.4.

CarouselState owns selection, orientation, looping, snapping, and gesture state. Retain one state entity and pass it to Carousel, CarouselContent, every CarouselItem, and the optional controls. The application can subscribe to CarouselEvent::Change or update the state directly for controlled selection.

The content handles pointer drags, precise trackpad gestures, wheel steps, and horizontal or vertical snapping. The focused carousel uses Arrow keys, Home, and End. Looping uses GPUI Kit's runway layout rather than cloning slide elements. GPUI Kit reads the operating system reduced-motion setting at initialization and removes carousel travel when the preference is active. Carousel snapping uses GPUI Kit's semantic spring; gpuicn's UiTheme.motion does not retune that spring.

gpuicn styles the region, arrow controls, and pagination dots with the shared Nova theme. Arrow and pagination actions retain GPUI Kit's frame placement, focus handling and selection behavior. UiTheme::set projects the mode, focus ring, shared colors, fonts and radii used by those controls and the carousel root into GPUI Kit's component theme after a light/dark or custom theme change. Slide content remains application-owned. Keep item IDs stable, give the region a useful accessible label, and give custom slide content its own labels when the generated “Slide N of M” name is not enough.

Native pointer and keyboard checks do not establish performance or full screen-reader parity on every desktop platform. Browser preview checks cover the same Rust component through the WASM renderer.