Tabs
Organise content into switchable panels.
use herogpui::components::tabs::{TabItem, Tabs};Tabs on docs.rsSource at v0.14.0
Usage
Showing Usage, live: HeroGPUI compiled to WebAssembly. The canvas has no accessibility tree; each example's description and code below carry the same information.
Tab labels use a 20px line height independently of surrounding text. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-usage",
vec![
TabItem::new("photos", "Photos")
.content(gpui::div().child("Your photo library.")),
TabItem::new("music", "Music")
.content(gpui::div().child("Playlists and albums.")),
TabItem::new("videos", "Videos")
.content(gpui::div().child("Everything you have filmed.")),
],
"photos",
)
.into_any_element()])`text_size` replaces the tab labels' size step; a 12/14/16px size takes v3's 16/20/24 leading. Each tab keeps its fixed height and padding. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-label-size",
vec![
TabItem::new("a", "Account")
.content(gpui::div().child("12px labels.")),
TabItem::new("b", "Billing")
.content(gpui::div().child("12px labels.")),
],
"a",
)
.text_size(px(12.))
.into_any_element()])`size` is additive, not a prop: `Md` is the 32px box with 16px padding and a 14px label; `Sm` is 28/12/12 with 16px leading. The secondary underline keeps its thickness. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{TabItem, Tabs, TabsSize};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
Tabs::new(
"tabs-size-sm",
vec![
TabItem::new("a", "Account")
.content(gpui::div().child("Small step.")),
TabItem::new("b", "Billing")
.content(gpui::div().child("Small step.")),
],
"a",
)
.size(TabsSize::Sm)
.into_any_element(),
Tabs::new(
"tabs-size-md",
vec![
TabItem::new("a", "Account")
.content(gpui::div().child("Medium step.")),
TabItem::new("b", "Billing")
.content(gpui::div().child("Medium step.")),
],
"a",
)
.size(TabsSize::Md)
.into_any_element(),
])An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{Orientation, TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-vertical",
vec![
TabItem::new("account", "Account")
.content(gpui::div().child("Name, email and password.")),
TabItem::new("billing", "Billing")
.content(gpui::div().child("Cards and invoices.")),
TabItem::new("team", "Team")
.content(gpui::div().child("Members and roles.")),
],
"account",
)
.orientation(Orientation::Vertical)
.into_any_element()])`vertical_tab_min_width` replaces the 80px floor every tab of a vertical list keeps; a tab's own `width` still wins over it. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{Orientation, TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-vertical-min-width",
vec![
TabItem::new("a", "A").content(gpui::div().child("A 48px rail.")),
TabItem::new("b", "B").content(gpui::div().child("Second pane.")),
TabItem::new("c", "C").content(gpui::div().child("Third pane.")),
],
"a",
)
.orientation(Orientation::Vertical)
.vertical_tab_min_width(px(48.))
.into_any_element()])The label slot releases its min-content width, so long labels wrap inside constrained horizontal shares and vertical tab columns. Tabs keep the fixed 32px height and ship no truncation utility, so wrapped lines paint past the pill exactly as the v3.2.5 sheet renders them. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{Orientation, TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
Tabs::new(
"tabs-wrapping",
vec![
TabItem::new(
"profile",
"Profile and account security and recovery",
)
.content(gpui::div().child("Security settings.")),
TabItem::new(
"notifications",
"Notification preferences and delivery",
)
.content(gpui::div().child("Notification settings.")),
TabItem::new("billing", "Billing and invoices")
.content(gpui::div().child("Billing settings.")),
],
"profile",
)
.full_width(true)
.sx(|el| el.w(px(320.)))
.into_any_element(),
gpui::div()
.w(px(320.))
.h(px(180.))
.child(
Tabs::new(
"tabs-vertical-wrapping",
vec![
TabItem::new(
"profile",
"Profile and account security and recovery settings",
)
.content(gpui::div().child("Security settings.")),
TabItem::new("billing", "Billing and invoices")
.content(gpui::div().child("Billing settings.")),
],
"profile",
)
.orientation(Orientation::Vertical)
.into_any_element(),
)
.into_any_element(),
])Stretches a tab list with `<Tabs.List style="w-full">` and `<Tabs.Trigger style="flex-1">`; `full_width(true)` is that pair. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![gpui::div()
.w(px(420.))
.child(
Tabs::new(
"tabs-full-width",
vec![
TabItem::new("all", "All")
.content(gpui::div().child("Everything.")),
TabItem::new("unread", "Unread")
.content(gpui::div().child("Only unread.")),
TabItem::new("archived", "Archived")
.content(gpui::div().child("Put away.")),
],
"all",
)
.full_width(true),
)
.into_any_element()])Align content inside each tab to Start, Center (the default), or End. Selection and the indicator keep the same geometry. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{Orientation, TabItem, Tabs, TabsAlign, TabsVariant};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(TabsVariant::ALL.into_iter().map(|variant| {
gpui::div()
.flex()
.flex_wrap()
.items_center()
.justify_center()
.gap(px(12.))
.children(TabsAlign::ALL.into_iter().map(|align| {
Tabs::new(
format!("tabs-align-{variant:?}-{align:?}"),
vec![
TabItem::new("general", "General"),
TabItem::new("billing", "Subscription & Billing"),
TabItem::new("privacy", "Privacy"),
],
"general",
)
.variant(variant)
.orientation(Orientation::Vertical)
.align(align)
.sx(|el| el.h(px(116.)))
.into_any_element()
}))
.into_any_element()
}))More tabs than fit scroll along their axis. Wheel input from the other axis continues to the page. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{ActiveTheme, Orientation, TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{App, SharedString, px};
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
para("Horizontal", cx),
// The list only overflows inside a bounded box, which is
// how v3's own example frames it.
gpui::div()
.w(px(420.))
.child(Tabs::new(
"tabs-overflow",
(1..=12)
.map(|n| {
TabItem::new(
SharedString::from(format!("t{n}")),
SharedString::from(format!("Section {n}")),
)
.content(gpui::div().child(format!("Content {n}")))
})
.collect(),
"t1",
))
.into_any_element(),
para("Vertical", cx),
gpui::div()
.h(px(200.))
.child(
Tabs::new(
"tabs-overflow-vertical",
(1..=8)
.map(|n| {
TabItem::new(
SharedString::from(format!("v{n}")),
SharedString::from(format!("Section {n}")),
)
.content(gpui::div().child(format!("Content {n}")))
})
.collect(),
"v1",
)
.orientation(Orientation::Vertical),
)
.into_any_element(),
])Also calls para (included when you copy).
An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-disabled",
vec![
TabItem::new("open", "Open")
.content(gpui::div().child("Open items.")),
// v3: `<Tabs.Tab isDisabled>` on a single tab; the
// selected "open" tab stays live.
TabItem::new("closed", "Closed")
.is_disabled(true)
.content(gpui::div().child("Closed items.")),
],
"open",
)
.into_any_element()])An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-separator",
vec![
TabItem::new("one", "One").content(gpui::div().child("First.")),
// v3: `<Tabs.Separator />` inside every tab but
// the first.
TabItem::new("two", "Two")
.separator()
.content(gpui::div().child("Second.")),
TabItem::new("three", "Three")
.separator()
.content(gpui::div().child("Third.")),
],
"one",
)
.into_any_element(),])The capture-toolbar shape: `trigger` renders an element instead of the label text — `label` stays the accessible name — and `radius`, `list_bg`, `indicator_bg` and `indicator_shadow` restyle the tray and its pill. The icon carries no accessible name of its own and svg does not inherit the tab's text colour, so it is set explicitly. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{ActiveTheme, TabItem, Tabs, icons};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-icon-segmented",
vec![
TabItem::new("light", "Light").trigger(
gpui::svg()
.size(px(16.))
.path(icons::SUN)
.text_color(cx.colors().foreground),
),
TabItem::new("dark", "Dark").trigger(
gpui::svg()
.size(px(16.))
.path(icons::MOON)
.text_color(cx.colors().foreground),
),
TabItem::new("system", "System").trigger(
gpui::svg()
.size(px(16.))
.path(icons::GEAR)
.text_color(cx.colors().foreground),
),
],
"light",
)
.radius(px(24.))
.list_bg(cx.colors().muted.alpha(0.10))
.indicator_bg(cx.colors().muted.alpha(0.25))
.indicator_shadow(false)
.into_any_element()])The fixed-box toolbar shape: `width`, `height` and `padding_x` fix every icon segment at 32px with no side padding — spells that `TabsTrigger style="size-8 p-0"` — while `list_padding(0)` removes the primary tray's inset the way `Tabs.List style="p-0"` does, and `hover_fill(false)` drops the unselected-tab wash so the pointer only changes the label colour, not the fill. An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{ActiveTheme, TabItem, Tabs, icons};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-icon-toolbar",
vec![
TabItem::new("preview", "Preview")
.width(px(32.))
.height(px(32.))
.padding_x(px(0.))
.trigger(
gpui::svg()
.size(px(16.))
.path(icons::EYE)
.text_color(cx.colors().foreground),
),
TabItem::new("duplicate", "Duplicate")
.width(px(32.))
.height(px(32.))
.padding_x(px(0.))
.trigger(
gpui::svg()
.size(px(16.))
.path(icons::COPY)
.text_color(cx.colors().foreground),
),
TabItem::new("discard", "Discard")
.width(px(32.))
.height(px(32.))
.padding_x(px(0.))
.trigger(
gpui::svg()
.size(px(16.))
.path(icons::TRASH)
.text_color(cx.colors().foreground),
),
],
"preview",
)
.list_padding(px(0.))
.hover_fill(false)
.into_any_element()])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{TabItem, Tabs, TabsVariant};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{SharedString, px};
use herogpui::gpui;
let secondary = self.tab_underline.clone();
let items = || {
vec![
TabItem::new("home", "Home").content(gpui::div().child("The home panel.")),
TabItem::new("music", "Music").content(gpui::div().child("The music panel.")),
TabItem::new("videos", "Videos").content(gpui::div().child("The videos panel.")),
]
};
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-secondary",
items(),
secondary.clone(),
)
.selected_key(secondary)
.variant(TabsVariant::Secondary)
.on_selection_change(cx.listener(|this, key: &SharedString, _, cx| {
this.tab_underline = key.clone();
cx.notify();
}))
.into_any_element()])An element expression with window: &mut Window and cx: &mut App in scope.
use herogpui::prelude::{Orientation, TabItem, Tabs, TabsVariant};
use herogpui::gpui::prelude::*;
use herogpui::gpui::px;
use herogpui::gpui;
let items = || {
vec![
TabItem::new("home", "Home").content(gpui::div().child("The home panel.")),
TabItem::new("music", "Music").content(gpui::div().child("The music panel.")),
TabItem::new("videos", "Videos").content(gpui::div().child("The videos panel.")),
]
};
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new(
"tabs-secondary-vertical",
items(),
"home",
)
.variant(TabsVariant::Secondary)
.orientation(Orientation::Vertical)
.into_any_element()])Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{SharedString, px};
use herogpui::gpui;
let primary = self.tab_solid.clone();
let items = || {
vec![
TabItem::new("home", "Home").content(gpui::div().child("The home panel.")),
TabItem::new("music", "Music").content(gpui::div().child("The music panel.")),
TabItem::new("videos", "Videos").content(gpui::div().child("The videos panel.")),
]
};
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![Tabs::new("tabs-primary", items(), primary.clone())
.selected_key(primary)
.on_selection_change(cx.listener(|this, key: &SharedString, _, cx| {
this.tab_solid = key.clone();
cx.notify();
}))
.into_any_element()])HeroGPUI extension, not a example: `Sidebar` is an application sidebar with a header, groups of menu items (icon, label, badge) and a footer. The menu is one tab stop: Up and Down move over the items and the Projects heading (skipping the disabled Archive), Right and Left expand and collapse a group, Enter selects. The footer button collapses it to icons, each named by a tooltip. Runs in a view's render: self is the view holding this example's state and cx is its Context<Self>.
use herogpui::prelude::{ActiveTheme, IconName, Sidebar, SidebarGroup, SidebarItem, TabItem, Tabs};
use herogpui::gpui::prelude::*;
use herogpui::gpui::{SharedString, px};
use herogpui::gpui;
gpui::div()
.flex()
.flex_col()
.items_start()
.justify_center()
.gap(px(12.))
.children(vec![
gpui::div()
.h(px(420.))
.w(px(560.))
.flex()
.rounded(px(12.))
.border_1()
.border_color(cx.colors().border)
.overflow_hidden()
.child(
Sidebar::new(
"tabs-sidebar-nav",
vec![
SidebarGroup::new("main").items(vec![
SidebarItem::new("home", "Home").icon(IconName::House),
SidebarItem::new("inbox", "Inbox").icon(IconName::Inbox).badge("12"),
SidebarItem::new("calendar", "Calendar").icon(IconName::Calendar),
SidebarItem::new("archive", "Archive").icon(IconName::Archive).is_disabled(true),
]),
SidebarGroup::new("projects")
.label("Projects")
.is_collapsible(true)
.items(vec![
SidebarItem::new("website", "Website").icon(IconName::Globe),
SidebarItem::new("dashboard", "Dashboard").icon(IconName::LayoutDashboard),
]),
SidebarGroup::new("account").label("Account").items(vec![
SidebarItem::new("team", "Team").icon(IconName::Users),
SidebarItem::new("settings", "Settings").icon(IconName::Settings),
]),
],
)
.width(px(220.))
.header(
gpui::div()
.px(px(8.))
.py(px(4.))
.font_weight(gpui::FontWeight::SEMIBOLD)
.whitespace_nowrap()
.child(if self.sidebar_collapsed { "A" } else { "Acme Inc." }),
)
.active_key(Some(self.sidebar_active.clone()))
.on_select(cx.listener(|this, key: &SharedString, _, cx| {
this.sidebar_active = key.clone();
cx.notify();
}))
.is_collapsed(self.sidebar_collapsed)
.on_collapsed_change(cx.listener(|this, collapsed: &bool, _, cx| {
this.sidebar_collapsed = *collapsed;
cx.notify();
})),
)
.child(
gpui::div()
.flex_1()
.p(px(20.))
.text_color(cx.colors().muted)
.child(format!("Page: {}", self.sidebar_active)),
)
.into_any_element(),
])Helpers used by these examples
Plain functions over the public API, defined once here and copied with each example that calls them.
fn para(text: &str, cx: &App) -> gpui::AnyElement {
let colors = cx.colors();
gpui::div()
.text_size(px(14.5))
.line_height(px(24.))
.text_color(colors.foreground)
.max_w(px(720.))
.child(text.to_owned())
.into_any_element()
}Anatomy
Rust types
Tabs is assembled from these builders. The API reference lists each one.
Customization
Shared appearance belongs on ThemeBuilder::components and .recipe("name"). Typed builders change how Tabs looks at one call site. Every builder also carries sx for placement, width and flex: it takes GPUI's own styling methods and refines them over the component's root after the theme, so an instance override wins. Behaviour builders live in the API reference below.
Styling
| Method | Values | Description |
|---|---|---|
variant(TabsVariant) | TabsVariant::Primary | TabsVariant::Secondary | Visual style variant: a filled segment or an underline. |
orientation(Orientation) | Orientation::Horizontal | Orientation::Vertical | Tab-list and panel layout orientation. |
// The one slot for caller-owned low-level styling: GPUI's
// styling methods, refined over the root element last.
.sx(|el| {
el.bg(gpui::rgba(0xffa500ff)) // background
.text_color(gpui::rgba(0x000000ff)) // text
.w(gpui::px(13.)) // size
.h(gpui::px(12.))
})API reference
Against HeroUI v3.2.6: 21 entries ported, 1 partial, 10 not ported. Partial rows are marked in the tables; entries not ported are listed under each table with the reason.
Builders
| Builder | Values | Default | Description |
|---|---|---|---|
align( | TabsAlign:: | TabsAlign:: | Aligns content inside each tab, independently of the panel and nested tab lists. Start/end use the port's left-to-right layout. |
variant( | TabsVariant:: | TabsVariant:: | Visual style variant: a filled segment or an underline. |
orientation( | Orientation:: | Orientation:: | Tab-list and panel layout orientation. |
selected_key( | string | — | Controlled selected tab key. |
default_selected_key( | string | — | Initial selected key for uncontrolled tabs. |
on_selection_change( | Fn( | — | Reports the selected key from tab activation. |
new( | string | — | Unique tab identifier and selection key. |
is_disabled( | true | | false | Removes one tab from pointer and roving-keyboard activation. |
Not ported10 HeroUI entries not ported
| HeroUI v3 | Why it is not ported |
|---|---|
Tabs.className | On the root. |
Tabs.render | Overrides the root element. |
Tabs.List.aria-label | Accessible label for the tab list. |
Tabs.List.className | On the tab list. |
Tabs.List.render | Overrides the tab-list element. |
Tabs.Tab.className | On one tab. |
Tabs.Tab.render | Overrides one tab's element. |
Tabs.Separator.className | On a separator. |
Tabs.Panel.className | On a panel. |
Tabs.Panel.render | Overrides the panel element. |
Parts
| Part | Description |
|---|---|
Tabs | Root flex container that orients the list and active panel. |
Tabs | Container owning the scroller and overflow chevrons. |
Tabs | Scrollable row or column and its shared roving focus handle. |
TabItem | One keyed, selectable tab segment; `TabItem::trigger` replaces the label text with an icon element while `label` stays the accessible name. |
TabItem | Optional pointer-inert hairline before a primary tab. |
Tabs | Measured filled pill or accent line following the selected tab. |
TabItem | Content associated with the selected tab key. |
States
| State | Builder | Description |
|---|---|---|
Selected | selected_key | Moves the indicator and applies selected foreground styling. |
Hovered | hover(.. opacity(0.7)) | Dims an enabled, unselected tab to 70% opacity. |
Focus visible | with_focus_ring | Draws the shared roving stop's focus-visible ring. |
Disabled | is_disabled || item.is_disabled | Dims the tab and removes it from pointer and keyboard activation. |
Controlled selection | selected_key | Moves roving focus and reports changes without mutating owner state. |
Uncontrolled selection | default_selected_key | Holds selection and repairs it when the selected item disappears. |