Tabs
Switch between sections or destinations.
Try it
Choose Account or Appearance to update the active tab. This preview runs the exact code sample below.
Basic use
use gpui::{Context};
use xgpui_luma::controls::tabs::TabsItem;
use xgpui_luma_look_radix as radix;
struct Example {
control: gpui::Entity<xgpui_luma::controls::tabs::Tabs>,
active_tab: String,
}
impl Example {
fn new(cx: &mut Context<Self>) -> Self {
let control = radix::Tabs::new("settings")
.items([TabsItem::new("account").label("Account"), TabsItem::new("appearance").label("Appearance")])
.active("account")
.on_value_changed(cx, |app, tab_id, _, cx| {
app.active_tab = tab_id.to_string();
cx.notify();
})
.spawn(cx);
Self { control, active_tab: "account".into() }
}
}Integration snippet, checked against the current SDK and Radix crates. cx is the initialized owner context. The control retains its callbacks; callback owners are held weakly. Render self.control.clone() in the owner’s view.
SDK behavior & customization
Supports navigation-only items and retained panels. Content rendering, layout and motion timing have separate extension points; see content transitions.
Relevant contracts: TabsRenderModel, TabsTemplateHandlers, TabsTemplateModifier, TabsTemplate.
Use the module’s template contract for structural changes. Builder modifiers and item templates vary by control.
Content panels and custom transitions
Add retained entities with .tab(id, label, entity) or content presenters with .tab_with(id, label, presenter). Render the combined control, or place tab_list() and body() separately. Presenters rebuild elements while captured entities retain their state. Observe Tabs for programmatic selection changes; set_active does not emit a user Change event. Use refresh_content when app-owned data read by a presenter changes.
TabsTemplate customizes navigation chrome. TabsContentTransition customizes panel rendering. The SDK and Radix builders expose .content_transition(...) and .content_layout(...); timing remains in TabsMotion::content. Without a custom renderer, the existing incoming fade remains unchanged (instant with vanilla SDK defaults).
mod content_transition_example {
use std::time::Duration;
use gpui::{Context, Entity, div, prelude::*};
use xgpui_luma::{
controls::tabs::{Tabs, TabsContentLayout, TabsContentTransition},
motion::{Easing, Transition},
};
use xgpui_luma_look_radix::{self as radix, Look};
fn pages<M: 'static>(look: &Look, cx: &mut Context<M>) -> Entity<Tabs> {
// Application-defined rendering; no SDK change is required.
let fade = TabsContentTransition::new(|frame, _, _| {
div().opacity(frame.progress)
.children(frame.incoming.map(|panel| panel.element))
});
radix::Tabs::new("pages").look(look)
.content_layout(TabsContentLayout::Content)
.content_transition(fade)
.motion(|motion| motion.content = Transition::timed(
Duration::from_millis(300), Easing::OutCubic,
))
.tab_with("account", "Account", |_, _| div().child("Account settings"))
.tab_with("appearance", "Appearance", |_, _| div().child("Appearance settings"))
.spawn(cx)
}
}Choose layout independently
Autopreserves the existing layout: a separate body slot flexes while the combined Tabs root follows its content.Contentlets in-flow content determine height, including separately placed body slots.Fillfills and clips a bounded flex viewport. Use it for absolutely positioned slide/stack effects, and give the parent a definite height or available flex space.
Selecting an effect does not change the layout policy. Absolute positioning can remain in place when a viewport-filling effect settles.
Retarget a stateful effect
Use TabsContentTransition::with_factory(MyRenderer::default) with a type implementing TabsTransitionRenderer. The factory creates independent renderer state for each Tabs instance, including when a transition configuration is cloned.
selection_changed(TabsTransitionChange)receives ordered notifications, even when multiple selections occur between renders. The initial notification and cancellation/instant changes haveanimated=false; settle immediately in that case.- On interruption, sample the prior poses with
previous_progress, then set new destinations from those sampled values. Do not reset every panel to its starting position.directionfollows current navigation item order. render(TabsTransitionFrame, window, cx)receives the incoming panel, a vector of all retiring panels, eased progress, direction, and animation state. Previously retiring panels survive further interruptions until completion, cancellation, or their removal.- The SDK owns the clock, body-only frame notifications, panel lifetime and focus scopes. Reuse each panel's stable
idfor positioning wrappers. Nested GPUI IDs are scoped by ancestry. - Paint incoming content last when panels overlap. Retiring panels are individually shielded from pointer input; incoming content stays interactive.
If keyboard focus is inside an inactive panel, the SDK returns it to native navigation. .focus_target(...) can supply another destination, such as external page-navigation controls; returning None uses the native fallback. Focus outside inactive panels is preserved. The provider may be queried while rendering and should resolve a handle without changing unrelated focus.
Prefer relative GPUI lengths for positioning. .measure_viewport(true) opts into last-measured bounds and a post-layout redraw when bounds change. frame.viewport is None before measurement and whenever measurement is disabled. The default requires no measurement follow-up frame.
App-owned screen state
Keep a separate ScrollHandle per screen to preserve offsets, and paint each screen's background inside its sliding panel. Shared navigation chrome can be a separate entity, with its own motion and actual-offset updates. Notify the screen owner for selection/data changes rather than every bounds-change event or wheel event.
Radix Studio uses this public API for its directional slide, with a local 400 ms override, retained scroll positions, panel-owned backgrounds and independently animated navigation chrome. See the stateful slide renderer, control bar, and SDK transition contract.
Events
Callbacks follow the existing SDK event stream. Registrations append, hold the owner weakly, and live until the control is released. cx.notify() requests an owner redraw. Compound payloads are borrowed; scalar values are passed by value.
| Event / payload | Callback |
|---|---|
Change { tab_id: SharedString, label: SharedString } | .on_value_changed(cx, |app, tab_id, label, cx| { … }) |
Activate { tab_id: SharedString, label: SharedString } | .on_activate(cx, |app, tab_id, label, cx| { … }) |
Reactivate { tab_id: SharedString, label: SharedString } | .on_reactivate(cx, |app, tab_id, label, cx| { … }) |
DropdownRequested { tab_id: SharedString, label: SharedString, bounds: Option<Bounds<Pixels>> } | .on_dropdown_requested(cx, |app, tab_id, label, bounds, cx| { … }) |
ItemBoundsChanged { tab_id: SharedString, bounds: Bounds<Pixels> } | .on_item_bounds_changed(cx, |app, tab_id, bounds, cx| { … }) |
FocusChanged { focused: bool } | .on_focus_changed(cx, |app, focused, cx| { … }) |
ItemFocused { tab_id: SharedString, label: SharedString } | .on_item_focused(cx, |app, tab_id, label, cx| { … }) |
Use .on_event(cx, |app, event, cx| { … }) for all events. Factory-created controls support the same callbacks through TabsEventExt. Existing cx.subscribe observers remain supported.
Drag and drop
See Drag and drop for lifecycle ordering, callbacks, ownership, cancellation and the coverage matrix.
Enable .reordering(true) on SDK, Shadcn or Radix Tabs builders, or call set_reordering. Native gestures move one stable tab ID within its own strip. Disabled tabs and dropdown triggers cannot initiate drags; insertion gaps beside them remain valid. Each insertion gap has one target spanning the neighboring tab halves, with one themed line centered between the tabs. Overflowing strips scroll horizontally, including edge scrolling during a drag.
TabsDragEvent is DragDropEvent<EntityId, SharedString>; before=None appends. The active ID, focus, retained contents/presenters/entities and body transition survive reorder. A drag does not activate/reactivate tabs or request a dropdown. SDK commits are automatic; set_drop_handler can synchronously call commit_drop or reject_drop. An unanswered handler rejects. Replacing items, disabling the control or disabling reordering cancels the session.
Only same-strip moves are admitted. Cross-strip transfer/detachment is outside this API; removal/addition helpers are filters and never synthesized. Custom templates use TabsRenderModel.drag_drop to call bind_tab_drag, bind_drag_strip and (once per strip) bind_tab_drop_targets. Static template previews set this field to None. Decorative accessories follow the tab; custom embedded controls use the SDK drag_boundary().
mod dnd_example {
use gpui::{Context, Entity};
use xgpui_luma::controls::tabs::{Tabs, TabsItem};
use xgpui_luma_look_shadcn::{ShadcnLook, Tabs as ThemedTabs};
struct Owner;
fn strip(look: &ShadcnLook, cx: &mut Context<Owner>) -> Entity<Tabs> {
ThemedTabs::new("documents").look(look).reordering(true)
.items([TabsItem::new("overview"), TabsItem::new("activity"),
TabsItem::new("locked").enabled(false), TabsItem::new("more").dropdown_trigger()])
.active("activity")
.on_drag_drop(cx, |_, event, _| { let _ = event; })
.on_items_reordered(cx, |_, _, keys, before, _| { let _ = (keys, before); })
.spawn(cx)
}
}Committed changes also support shared drop-layout motion, including duration settings, reduced motion and documented virtualization/transfer limits.