Split Button

An action face with an adjacent menu trigger.

Try it

Interact with the control below. This preview runs the exact code sample shown on this page.

Open example separately ↗

Basic use

Radix · integration snippet
use xgpui_luma_look_radix as radix;
use gpui::{Context};
use xgpui_luma::controls::split_button::SplitButtonEvent;
use xgpui_luma::infra::{menu_item::MenuItem, presenter::HasPresenter};

struct Example {
    control: gpui::Entity<xgpui_luma::controls::split_button::SplitButton>,
    last_action: String,
}

impl Example {
    fn new(cx: &mut Context<Self>) -> Self {
        let look = cx.try_global::<radix::Look>().cloned().unwrap_or_else(radix::Look::built_in);
        let control = radix::split_button_builder(&look, "save", radix::PopupMenuVariant::Solid, radix::Tone::Accent)
            .label("Save")
            .items([MenuItem::new("save-as").label("Save as")])
            .on_event(cx, |app, event: &SplitButtonEvent, cx| {
                match event {
                    SplitButtonEvent::ActionClick => {
                        app.last_action = "save".into();
                    }
                    SplitButtonEvent::Select { item_id, .. } => {
                        app.last_action = item_id.to_string();
                    }
                    _ => return,
                }
                cx.notify();
            })
            .spawn(cx);

        Self { control, last_action: String::new() }
    }
}

Integration snippet, checked against the current SDK. 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.

Radix styling

split_button_builder(&look, id, variant, tone) returns the full SDK control. Solid starts with a Primary trigger and Soft with Secondary; .trigger_style(...) selects Outline or Ghost. Menu highlight variants are Solid and Soft. .primary_enabled(false) disables only the action face; keyboard/menu access and semantic callbacks remain available.

Pass an explicit Look, including forks. Theme adapters retain that binding; notify affected controls and their owners after palette, mode, or stylesheet edits. Resolved look values are snapshots and must be resolved again during render.

See the Radix control support table for adapter and utility status.

SDK behavior & customization

A distinct retained SDK control with its own builder, semantic events, and template boundary. It composes the shared PopupMenu engine for navigation, lifecycle, placement, and focus. Handle ActionClick separately from menu Select events.

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 / payloadCallback
ActionClick.on_action_click(cx, |app, cx| { … })
Select { item_id: SharedString, label: SharedString }.on_select(cx, |app, item_id, label, cx| { … })
OpenChanged { open: bool }.on_open_changed(cx, |app, open, cx| { … })
Dismiss.on_dismiss(cx, |app, cx| { … })
FocusChanged { focused: bool }.on_focus_changed(cx, |app, focused, cx| { … })
HoverChanged { hovered: bool }.on_hover_changed(cx, |app, hovered, cx| { … })
EnabledChanged { enabled: bool }.on_enabled_changed(cx, |app, enabled, cx| { … })

Use .on_event(cx, |app, event, cx| { … }) for all events. Factory-created controls support the same callbacks through SplitButtonEventExt. Existing cx.subscribe observers remain supported.

Interaction and disabled states

Primary click or Enter/Space while closed executes the primary action. Disclosure toggles the menu; Down/Up opens it at the first/last enabled item. While open, arrows navigate and Enter/Space selects. Escape dismisses and retains focus. Tab/Shift-Tab leaves the single tab stop and dismisses through focus loss. Bind the SDK’s default control and focus keys inside a normal focus scope.

.primary_enabled(false) and set_primary_enabled(false, cx) block only the primary action. The disclosure and alternate actions remain accessible. .enabled(false) blocks both faces and removes the tab stop. Each face has independent hover and pressed feedback.

Opening emits OpenChanged(true). Selection closes before emitting Select and does not emit Dismiss. Escape, outside click, focus loss, or a second disclosure click closes before emitting Dismiss. Global disabling or replacing items closes without a user-dismiss event. Primary activation while open dismisses before ActionClick.

Focus and accessibility

One focus handle represents the logical control. The template exposes a named group with separately named primary and disclosure button nodes, independent disabled metadata, advertised keyboard shortcuts, and disclosure menu/expanded metadata. Selection and Escape retain trigger focus. Labels remain meaningful with custom icon content.

Shadcn and customization

shadcn::SplitButton::new returns an SDK Entity<SplitButton> at spawn. Use .look(look), .primary(), .secondary(), .outline(), or .ghost(). ShadcnLook::split_button_theme() and split_button_template() provide its shared visual recipe and dedicated template contract.

Customize face content through HasPresenter, small render changes through with_template_modifier, or replace SplitButtonTemplate. Render snapshots and handler shapes are intentionally shared with PopupMenu and include split_state.primary and split_state.disclosure. See How to create a control specialization for the template and behavior pattern.

Migration from the preset

The previous SplitButton names aliased PopupMenu. Replace explicit Entity<PopupMenu>, PopupMenuEvent, and PopupMenuEventExt annotations with their SplitButton counterparts when using the supported control. Event variant and callback names remain the same. Shadcn builder chains retain their existing shape.

PopupMenu::new(...).split() remains a compatibility preset. Convert an existing configured SDK builder using SplitButtonBuilder::from_popup_builder; this preserves its popup template and existing popup callbacks while new callbacks use SplitButtonEvent. Custom templates can move to the dedicated SplitButtonTemplate trait with shared presentation types.

Source

SDK module · Radix adapter