ui: Add `ToggleButtonGroup` component (#35118)

Danilo Leal and MrSubidubi created

<img width="600" height="704" alt="CleanShot 2025-07-25 at 8  03 04@2x"
src="https://github.com/user-attachments/assets/3d2b29ba-e0fd-4231-bb80-746903d61481"
/>

Release Notes:

- N/A

---------

Co-authored-by: MrSubidubi <dev@bahn.sh>

Change summary

crates/onboarding/src/onboarding.rs              |  63 -
crates/ui/src/components/button/toggle_button.rs | 626 +++++++++++++++++
2 files changed, 651 insertions(+), 38 deletions(-)

Detailed changes

crates/onboarding/src/onboarding.rs 🔗

@@ -10,10 +10,8 @@ use settings::{Settings, SettingsStore, update_settings_file};
 use std::sync::Arc;
 use theme::{ThemeMode, ThemeSettings};
 use ui::{
-    ButtonCommon as _, ButtonSize, ButtonStyle, Clickable as _, Color, Divider, FluentBuilder,
-    Headline, InteractiveElement, KeyBinding, Label, LabelCommon, ParentElement as _,
-    StatefulInteractiveElement, Styled, ToggleButton, Toggleable as _, Vector, VectorName, div,
-    h_flex, rems, v_container, v_flex,
+    Divider, FluentBuilder, Headline, KeyBinding, ParentElement as _, StatefulInteractiveElement,
+    ToggleButtonGroup, ToggleButtonSimple, Vector, VectorName, prelude::*, rems_from_px,
 };
 use workspace::{
     AppState, Workspace, WorkspaceId,
@@ -222,39 +220,30 @@ impl Onboarding {
     fn render_basics_page(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let theme_mode = read_theme_selection(cx);
 
-        v_container().child(
-            h_flex()
-                .items_center()
-                .justify_between()
-                .child(Label::new("Theme"))
-                .child(
-                    h_flex()
-                        .rounded_md()
-                        .child(
-                            ToggleButton::new("light", "Light")
-                                .style(ButtonStyle::Filled)
-                                .size(ButtonSize::Large)
-                                .toggle_state(theme_mode == ThemeMode::Light)
-                                .on_click(|_, _, cx| write_theme_selection(ThemeMode::Light, cx))
-                                .first(),
-                        )
-                        .child(
-                            ToggleButton::new("dark", "Dark")
-                                .style(ButtonStyle::Filled)
-                                .size(ButtonSize::Large)
-                                .toggle_state(theme_mode == ThemeMode::Dark)
-                                .on_click(|_, _, cx| write_theme_selection(ThemeMode::Dark, cx))
-                                .last(),
-                        )
-                        .child(
-                            ToggleButton::new("system", "System")
-                                .style(ButtonStyle::Filled)
-                                .size(ButtonSize::Large)
-                                .toggle_state(theme_mode == ThemeMode::System)
-                                .on_click(|_, _, cx| write_theme_selection(ThemeMode::System, cx))
-                                .middle(),
-                        ),
-                ),
+        v_flex().child(
+            h_flex().justify_between().child(Label::new("Theme")).child(
+                ToggleButtonGroup::single_row(
+                    "theme-selector-onboarding",
+                    [
+                        ToggleButtonSimple::new("Light", |_, _, cx| {
+                            write_theme_selection(ThemeMode::Light, cx)
+                        }),
+                        ToggleButtonSimple::new("Dark", |_, _, cx| {
+                            write_theme_selection(ThemeMode::Dark, cx)
+                        }),
+                        ToggleButtonSimple::new("System", |_, _, cx| {
+                            write_theme_selection(ThemeMode::System, cx)
+                        }),
+                    ],
+                )
+                .selected_index(match theme_mode {
+                    ThemeMode::Light => 0,
+                    ThemeMode::Dark => 1,
+                    ThemeMode::System => 2,
+                })
+                .style(ui::ToggleButtonGroupStyle::Outlined)
+                .button_width(rems_from_px(64.)),
+            ),
         )
     }
 

crates/ui/src/components/button/toggle_button.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{AnyView, ClickEvent};
 
-use crate::{ButtonLike, ButtonLikeRounding, ElevationIndex, prelude::*};
+use crate::{ButtonLike, ButtonLikeRounding, ElevationIndex, TintColor, prelude::*};
 
 /// The position of a [`ToggleButton`] within a group of buttons.
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -290,3 +290,627 @@ impl Component for ToggleButton {
         )
     }
 }
+
+mod private {
+    pub trait Sealed {}
+}
+
+pub trait ButtonBuilder: 'static + private::Sealed {
+    fn label(&self) -> impl Into<SharedString>;
+    fn icon(&self) -> Option<IconName>;
+    fn on_click(self) -> Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
+}
+
+pub struct ToggleButtonSimple {
+    label: SharedString,
+    on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
+}
+
+impl ToggleButtonSimple {
+    pub fn new(
+        label: impl Into<SharedString>,
+        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        Self {
+            label: label.into(),
+            on_click: Box::new(on_click),
+        }
+    }
+}
+
+impl private::Sealed for ToggleButtonSimple {}
+
+impl ButtonBuilder for ToggleButtonSimple {
+    fn label(&self) -> impl Into<SharedString> {
+        self.label.clone()
+    }
+
+    fn icon(&self) -> Option<IconName> {
+        None
+    }
+
+    fn on_click(self) -> Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> {
+        self.on_click
+    }
+}
+
+pub struct ToggleButtonWithIcon {
+    label: SharedString,
+    icon: IconName,
+    on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
+}
+
+impl ToggleButtonWithIcon {
+    pub fn new(
+        label: impl Into<SharedString>,
+        icon: IconName,
+        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        Self {
+            label: label.into(),
+            icon,
+            on_click: Box::new(on_click),
+        }
+    }
+}
+
+impl private::Sealed for ToggleButtonWithIcon {}
+
+impl ButtonBuilder for ToggleButtonWithIcon {
+    fn label(&self) -> impl Into<SharedString> {
+        self.label.clone()
+    }
+
+    fn icon(&self) -> Option<IconName> {
+        Some(self.icon)
+    }
+
+    fn on_click(self) -> Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> {
+        self.on_click
+    }
+}
+
+struct ToggleButtonRow<T: ButtonBuilder> {
+    items: Vec<T>,
+    index_offset: usize,
+    last_item_idx: usize,
+    is_last_row: bool,
+}
+
+impl<T: ButtonBuilder> ToggleButtonRow<T> {
+    fn new(items: Vec<T>, index_offset: usize, is_last_row: bool) -> Self {
+        Self {
+            index_offset,
+            last_item_idx: index_offset + items.len() - 1,
+            is_last_row,
+            items,
+        }
+    }
+}
+
+enum ToggleButtonGroupRows<T: ButtonBuilder> {
+    Single(Vec<T>),
+    Multiple(Vec<T>, Vec<T>),
+}
+
+impl<T: ButtonBuilder> ToggleButtonGroupRows<T> {
+    fn items(self) -> impl IntoIterator<Item = ToggleButtonRow<T>> {
+        match self {
+            ToggleButtonGroupRows::Single(items) => {
+                vec![ToggleButtonRow::new(items, 0, true)]
+            }
+            ToggleButtonGroupRows::Multiple(first_row, second_row) => {
+                let row_len = first_row.len();
+                vec![
+                    ToggleButtonRow::new(first_row, 0, false),
+                    ToggleButtonRow::new(second_row, row_len, true),
+                ]
+            }
+        }
+    }
+}
+
+#[derive(Clone, Copy, PartialEq)]
+pub enum ToggleButtonGroupStyle {
+    Transparent,
+    Filled,
+    Outlined,
+}
+
+#[derive(IntoElement)]
+pub struct ToggleButtonGroup<T>
+where
+    T: ButtonBuilder,
+{
+    group_name: SharedString,
+    rows: ToggleButtonGroupRows<T>,
+    style: ToggleButtonGroupStyle,
+    button_width: Rems,
+    selected_index: usize,
+}
+
+impl<T: ButtonBuilder> ToggleButtonGroup<T> {
+    pub fn single_row(
+        group_name: impl Into<SharedString>,
+        buttons: impl IntoIterator<Item = T>,
+    ) -> Self {
+        Self {
+            group_name: group_name.into(),
+            rows: ToggleButtonGroupRows::Single(Vec::from_iter(buttons)),
+            style: ToggleButtonGroupStyle::Transparent,
+            button_width: rems_from_px(100.),
+            selected_index: 0,
+        }
+    }
+
+    pub fn multiple_rows<const ROWS: usize>(
+        group_name: impl Into<SharedString>,
+        first_row: [T; ROWS],
+        second_row: [T; ROWS],
+    ) -> Self {
+        Self {
+            group_name: group_name.into(),
+            rows: ToggleButtonGroupRows::Multiple(
+                Vec::from_iter(first_row),
+                Vec::from_iter(second_row),
+            ),
+            style: ToggleButtonGroupStyle::Transparent,
+            button_width: rems_from_px(100.),
+            selected_index: 0,
+        }
+    }
+
+    pub fn style(mut self, style: ToggleButtonGroupStyle) -> Self {
+        self.style = style;
+        self
+    }
+
+    pub fn button_width(mut self, button_width: Rems) -> Self {
+        self.button_width = button_width;
+        self
+    }
+
+    pub fn selected_index(mut self, index: usize) -> Self {
+        self.selected_index = index;
+        self
+    }
+}
+
+impl<T: ButtonBuilder> RenderOnce for ToggleButtonGroup<T> {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let rows = self.rows.items().into_iter().map(|row| {
+            (
+                row.items
+                    .into_iter()
+                    .enumerate()
+                    .map(move |(index, item)| (index + row.index_offset, row.last_item_idx, item))
+                    .map(|(index, last_item_idx, item)| {
+                        (
+                            ButtonLike::new((self.group_name.clone(), index))
+                                .when(index == self.selected_index, |this| {
+                                    this.toggle_state(true)
+                                        .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                                })
+                                .rounding(None)
+                                .when(self.style == ToggleButtonGroupStyle::Filled, |button| {
+                                    button.style(ButtonStyle::Filled)
+                                })
+                                .child(
+                                    h_flex()
+                                        .min_w(self.button_width)
+                                        .gap_1p5()
+                                        .justify_center()
+                                        .when_some(item.icon(), |this, icon| {
+                                            this.child(Icon::new(icon).size(IconSize::XSmall).map(
+                                                |this| {
+                                                    if index == self.selected_index {
+                                                        this.color(Color::Accent)
+                                                    } else {
+                                                        this.color(Color::Muted)
+                                                    }
+                                                },
+                                            ))
+                                        })
+                                        .child(
+                                            Label::new(item.label())
+                                                .when(index == self.selected_index, |this| {
+                                                    this.color(Color::Accent)
+                                                }),
+                                        ),
+                                )
+                                .on_click(item.on_click()),
+                            index == last_item_idx,
+                        )
+                    }),
+                row.is_last_row,
+            )
+        });
+
+        let is_outlined_or_filled = self.style == ToggleButtonGroupStyle::Outlined
+            || self.style == ToggleButtonGroupStyle::Filled;
+        let is_transparent = self.style == ToggleButtonGroupStyle::Transparent;
+        let border_color = cx.theme().colors().border.opacity(0.6);
+
+        v_flex()
+            .rounded_md()
+            .overflow_hidden()
+            .map(|this| {
+                if is_transparent {
+                    this.gap_px()
+                } else {
+                    this.border_1().border_color(border_color)
+                }
+            })
+            .children(rows.map(|(items, last_row)| {
+                h_flex()
+                    .when(!is_outlined_or_filled, |this| this.gap_px())
+                    .when(is_outlined_or_filled && !last_row, |this| {
+                        this.border_b_1().border_color(border_color)
+                    })
+                    .children(items.map(|(item, last_item)| {
+                        div()
+                            .when(is_outlined_or_filled && !last_item, |this| {
+                                this.border_r_1().border_color(border_color)
+                            })
+                            .child(item)
+                    }))
+            }))
+    }
+}
+
+fn register_toggle_button_group() {
+    component::register_component::<ToggleButtonGroup<ToggleButtonSimple>>();
+}
+
+component::__private::inventory::submit! {
+    component::ComponentFn::new(register_toggle_button_group)
+}
+
+impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> {
+    fn name() -> &'static str {
+        "ToggleButtonGroup"
+    }
+
+    fn scope() -> ComponentScope {
+        ComponentScope::Input
+    }
+
+    fn sort_name() -> &'static str {
+        "ButtonG"
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        Some(
+            v_flex()
+                .gap_6()
+                .children(vec![example_group_with_title(
+                    "Transparent Variant",
+                    vec![
+                        single_example(
+                            "Single Row Group",
+                            ToggleButtonGroup::single_row(
+                                "single_row_test",
+                                [
+                                    ToggleButtonSimple::new("First", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Second", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Third", |_, _, _| {}),
+                                ],
+                            )
+                            .selected_index(1)
+                            .button_width(rems_from_px(100.))
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Single Row Group with icons",
+                            ToggleButtonGroup::single_row(
+                                "single_row_test_icon",
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "First",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Second",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Third",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                            )
+                            .selected_index(1)
+                            .button_width(rems_from_px(100.))
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Multiple Row Group",
+                            ToggleButtonGroup::multiple_rows(
+                                "multiple_row_test",
+                                [
+                                    ToggleButtonSimple::new("First", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Second", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Third", |_, _, _| {}),
+                                ],
+                                [
+                                    ToggleButtonSimple::new("Fourth", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Fifth", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Sixth", |_, _, _| {}),
+                                ],
+                            )
+                            .selected_index(3)
+                            .button_width(rems_from_px(100.))
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Multiple Row Group with Icons",
+                            ToggleButtonGroup::multiple_rows(
+                                "multiple_row_test_icons",
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "First",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Second",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Third",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "Fourth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Fifth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Sixth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                            )
+                            .selected_index(3)
+                            .button_width(rems_from_px(100.))
+                            .into_any_element(),
+                        ),
+                    ],
+                )])
+                .children(vec![example_group_with_title(
+                    "Outlined Variant",
+                    vec![
+                        single_example(
+                            "Single Row Group",
+                            ToggleButtonGroup::single_row(
+                                "single_row_test_outline",
+                                [
+                                    ToggleButtonSimple::new("First", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Second", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Third", |_, _, _| {}),
+                                ],
+                            )
+                            .selected_index(1)
+                            .style(ToggleButtonGroupStyle::Outlined)
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Single Row Group with icons",
+                            ToggleButtonGroup::single_row(
+                                "single_row_test_icon_outlined",
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "First",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Second",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Third",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                            )
+                            .selected_index(1)
+                            .button_width(rems_from_px(100.))
+                            .style(ToggleButtonGroupStyle::Outlined)
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Multiple Row Group",
+                            ToggleButtonGroup::multiple_rows(
+                                "multiple_row_test",
+                                [
+                                    ToggleButtonSimple::new("First", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Second", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Third", |_, _, _| {}),
+                                ],
+                                [
+                                    ToggleButtonSimple::new("Fourth", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Fifth", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Sixth", |_, _, _| {}),
+                                ],
+                            )
+                            .selected_index(3)
+                            .button_width(rems_from_px(100.))
+                            .style(ToggleButtonGroupStyle::Outlined)
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Multiple Row Group with Icons",
+                            ToggleButtonGroup::multiple_rows(
+                                "multiple_row_test",
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "First",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Second",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Third",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "Fourth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Fifth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Sixth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                            )
+                            .selected_index(3)
+                            .button_width(rems_from_px(100.))
+                            .style(ToggleButtonGroupStyle::Outlined)
+                            .into_any_element(),
+                        ),
+                    ],
+                )])
+                .children(vec![example_group_with_title(
+                    "Filled Variant",
+                    vec![
+                        single_example(
+                            "Single Row Group",
+                            ToggleButtonGroup::single_row(
+                                "single_row_test_outline",
+                                [
+                                    ToggleButtonSimple::new("First", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Second", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Third", |_, _, _| {}),
+                                ],
+                            )
+                            .selected_index(2)
+                            .style(ToggleButtonGroupStyle::Filled)
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Single Row Group with icons",
+                            ToggleButtonGroup::single_row(
+                                "single_row_test_icon_outlined",
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "First",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Second",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Third",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                            )
+                            .selected_index(1)
+                            .button_width(rems_from_px(100.))
+                            .style(ToggleButtonGroupStyle::Filled)
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Multiple Row Group",
+                            ToggleButtonGroup::multiple_rows(
+                                "multiple_row_test",
+                                [
+                                    ToggleButtonSimple::new("First", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Second", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Third", |_, _, _| {}),
+                                ],
+                                [
+                                    ToggleButtonSimple::new("Fourth", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Fifth", |_, _, _| {}),
+                                    ToggleButtonSimple::new("Sixth", |_, _, _| {}),
+                                ],
+                            )
+                            .selected_index(3)
+                            .button_width(rems_from_px(100.))
+                            .style(ToggleButtonGroupStyle::Filled)
+                            .into_any_element(),
+                        ),
+                        single_example(
+                            "Multiple Row Group with Icons",
+                            ToggleButtonGroup::multiple_rows(
+                                "multiple_row_test",
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "First",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Second",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Third",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                                [
+                                    ToggleButtonWithIcon::new(
+                                        "Fourth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Fifth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                    ToggleButtonWithIcon::new(
+                                        "Sixth",
+                                        IconName::AiZed,
+                                        |_, _, _| {},
+                                    ),
+                                ],
+                            )
+                            .selected_index(3)
+                            .button_width(rems_from_px(100.))
+                            .style(ToggleButtonGroupStyle::Filled)
+                            .into_any_element(),
+                        ),
+                    ],
+                )])
+                .into_any_element(),
+        )
+    }
+}