Combobox

A searchable dropdown for selecting one or multiple values from a list.

Install

#Select vs Combobox

FeatureSelectCombobox
Searchable✓ (optional)✓ (optional)
Multi-select—✓ (.multiple(true))
Custom trigger rendering—✓
Custom item rendering—✓
Footer action slot—✓

Use Select for simple single-value picking. Use Combobox when you need multi-select, a fully custom trigger, or custom item rendering.

#Import

use gpui_kit::component::combobox::{
    Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx,
};
use gpui_kit::component::searchable_list::{
    SearchableListItem, SearchableVec, SearchableGroup,
};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ComboItem, ComboboxTrigger, ComboSearch, Combobox } from "combobox.slint";
use iced::keyboard;
use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Element, Length, Subscription, Task, Theme};

#Usage

#Basic Single-Select

let state = cx.new(|cx| {
    ComboboxState::new(
        SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]),
        vec![], // no initial selection
        window,
        cx,
    )
    .searchable(true)
});

Combobox::new(&state)
    .placeholder("Select framework...")
    .search_placeholder("Search...")
    .w_full()
default-box := Combobox {
    items: root.frameworks;
    placeholder: "Select framework...";
    search-placeholder: "Search…";
}
// The whole of a combobox: the items, the caller's single choice, open flag
// and free text, and the five reports. The trigger is the field's frame — the
// same height, inset and edge as `Input` — and the panel is the popover's one
// surface around a search row and the menu's rows. `Item` is the shape the
// menu, the dropdown button and the select already share.
let basic = body_box(
    Combobox::new(items(slot::BASIC))
        .selected(app.selected[slot::BASIC])
        .query(app.query[slot::BASIC].clone())
        .open(app.open[slot::BASIC])
        .highlighted(app.highlighted_in(slot::BASIC))
        .placeholder("Select framework...")
        .search_placeholder("Search…")
        .width(Length::Fixed(TRIGGER_WIDTH))
        .on_open_change(|open| Message::Opened {
            slot: slot::BASIC,
            open,
        })
        .on_chosen(|index| Message::Chosen {
            slot: slot::BASIC,
            index,
        })
        .on_query(|query| Message::Queried {
            slot: slot::BASIC,
            query,
        })
        .on_highlight(|index| Message::Highlighted {
            slot: slot::BASIC,
            index,
        })
        .on_leave(|row| Message::Left {
            slot: slot::BASIC,
            row,
        }),
);

#Multi-Select

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// `multiple(true)` makes a click toggle the row into `picked` instead of
// setting `selected`; the chosen rows carry the menu's check mark and the
// panel stays open until Escape or a click outside closes it.
let multiple = body_box(
    combo(app, slot::MULTI)
        .multiple(true)
        .placeholder("Select frameworks..."),
);

Pass .multiple(true) to enable multi-select mode. Clicking an item toggles it; the dropdown stays open until the user presses Escape or clicks outside.

let state = cx.new(|cx| {
    ComboboxState::new(
        SearchableVec::new(vec!["React", "Vue", "Angular"]),
        vec![IndexPath::new(0)], // pre-selected
        window,
        cx,
    )
    .multiple(true)
    .searchable(true)
});

Combobox::new(&state).placeholder("Select frameworks")
multiple-box := Combobox {
    items: root.frameworks;
    multiple: true;
    picked: [
        false,
        false,
        false,
        false,
        false
    ];
    placeholder: "Select frameworks...";
    search-placeholder: "Search…";
}

#Pre-selected Item

Pass index paths of items to pre-select:

let state = cx.new(|cx| {
    ComboboxState::new(items, vec![IndexPath::new(0)], window, cx)
});
SlintNo Slint version of this example yet.

#Grouped Items

Use SearchableGroup to group items under a heading:

let grouped = SearchableVec::new(vec![
    SearchableGroup::new("Fruits").items(vec![
        FoodItem::new("Apples"),
        FoodItem::new("Bananas"),
    ]),
    SearchableGroup::new("Vegetables").items(vec![
        FoodItem::new("Carrots"),
        FoodItem::new("Spinach"),
    ]),
]);

let state = cx.new(|cx| {
    ComboboxState::new(grouped, vec![], window, cx).searchable(true)
});

Combobox::new(&state)
grouped-box := Combobox {
    selected: 0;
    placeholder: "Select item...";
    search-placeholder: "Search…";
    items: [
        { label: "Apples", value: "apples", icon: "", group: "Fruits", badge: "", disabled: false, pinned: false },
        { label: "Bananas", value: "bananas", icon: "", group: "Fruits", badge: "", disabled: false, pinned: false },
        { label: "Cherries", value: "cherries", icon: "", group: "Fruits", badge: "", disabled: false, pinned: false },
        { label: "Carrots", value: "carrots", icon: "", group: "Vegetables", badge: "", disabled: false, pinned: false },
        { label: "Broccoli", value: "broccoli", icon: "", group: "Vegetables", badge: "", disabled: true, pinned: false },
        { label: "Spinach", value: "spinach", icon: "", group: "Vegetables", badge: "", disabled: false, pinned: false },
        { label: "Tea", value: "tea", icon: "", group: "Beverages", badge: "", disabled: false, pinned: false },
        { label: "Coffee", value: "coffee", icon: "", group: "Beverages", badge: "", disabled: true, pinned: false },
        { label: "Juice", value: "juice", icon: "", group: "Beverages", badge: "", disabled: false, pinned: false },
    ];
}

#Implementing SearchableListItem

Built-in implementations exist for String, SharedString, and &'static str. For custom types implement the trait:

#[derive(Clone)]
struct Country {
    name: SharedString,
    code: SharedString,
}

impl SearchableListItem for Country {
    type Value = SharedString;

    fn title(&self) -> SharedString {
        self.name.clone()
    }

    fn value(&self) -> &SharedString {
        &self.code
    }

    fn matches(&self, query: &str) -> bool {
        self.name.to_lowercase().contains(query)
            || self.code.to_lowercase().contains(query)
    }
}
SlintNo Slint version of this example yet.

#Disabled Items

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A row built with `disabled(true)` is drawn muted and answers no pointer or
// key, which is the Slint `dimmed` state.
let disabled_items = body_box(combo(app, slot::DISABLED_ITEMS).placeholder("Select item..."));

Return true from disabled() on items that should not be selectable:

impl SearchableListItem for MyItem {
    // ...
    fn disabled(&self) -> bool {
        self.is_unavailable
    }
}
Combobox {
    placeholder: "Select item...";
    search-placeholder: "Search…";
    items: [
        { label: "Apples", value: "apples", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Bananas", value: "bananas", icon: "", group: "", badge: "", disabled: true, pinned: false },
        { label: "Cherries", value: "cherries", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Carrots", value: "carrots", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Broccoli", value: "broccoli", icon: "", group: "", badge: "", disabled: true, pinned: false },
    ];
}

#Custom Check Icon

Combobox::new(&state)
    .check_icon(Icon::new(IconName::CircleCheck))
Combobox {
    items: root.frameworks;
    check-icon: "circle-check";
    placeholder: "Select framework...";
    search-placeholder: "Search…";
}
GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The footer band is the caller's element: here the plus glyph and a label,
// wrapped in the click that appends the typed name to the list. The component
// draws the band's one-pixel top edge and nothing else.
let footer = body_box(
    column![
        combo(app, slot::FOOTER)
            .placeholder("Select university")
            .footer(footer_action("New university")),
        muted_line("Type a name, then choose New university to add it to the list."),
    ]
    .spacing(8.0),
);

Render a persistent action at the bottom of the dropdown (e.g. an “Add new” button):

Combobox::new(&state)
    .footer(|_, cx| {
        Button::new("add-new")
            .ghost()
            .label("New item")
            .icon(Icon::new(IconName::Plus))
            .w_full()
            .justify_start()
            .into_any_element()
    })
Combobox {
    items <=> root.schools;
    query <=> root.school-query;
    selected: 0;
    footer: "New university";
    placeholder: "Select university";
    search-placeholder: "Search…";
    footer-clicked => {
        if root.school-query != "" {
            root.add-school(root.school-query);
            root.school-query = "";
        }
    }
}

#Custom Trigger

Override the entire trigger element. ComboboxTriggerCtx exposes the current selection, open/disabled flags, and size:

Combobox::new(&state)
    .render_trigger(|ctx, _, cx| {
        h_flex()
            .w_full()
            .items_center()
            .gap_2()
            .when(ctx.selection.is_empty(), |this| {
                this.text_color(cx.theme().muted_foreground)
                    .child("Select...")
            })
            .children(ctx.selection.iter().map(|(_, item)| {
                div()
                    .bg(cx.theme().accent)
                    .rounded_sm()
                    .px_1p5()
                    .py_0p5()
                    .text_sm()
                    .child(item.title())
            }))
            .into_any_element()
    })
Combobox {
    items: root.frameworks;
    trigger: ComboboxTrigger.chip;
    placeholder: "Select framework";
    search-placeholder: "Search…";
}

#Icons

Combobox::new(&state)
    .placeholder("Select industry category")
    .search_placeholder("Search…")
Combobox {
    trigger: ComboboxTrigger.icon;
    placeholder: "Select industry category";
    search-placeholder: "Search…";
    items: [
        { label: "Information Technology", value: "it", icon: "cpu", group: "", badge: "", disabled: false, pinned: false },
        { label: "Healthcare", value: "health", icon: "heart", group: "", badge: "", disabled: false, pinned: false },
        { label: "Finance", value: "finance", icon: "globe", group: "", badge: "", disabled: false, pinned: false },
        { label: "Education", value: "education", icon: "book-open", group: "", badge: "", disabled: false, pinned: false },
        { label: "Entertainment", value: "entertainment", icon: "star", group: "", badge: "", disabled: false, pinned: false },
    ];
}
// An `Item` carries the SVG the row draws in its leading slot; the bytes are
// the application's, as every icon in this crate is.
let icons = body_box(combo(app, slot::ICONS).placeholder("Select industry category"));

#Badges

Combobox::new(&state)
    .placeholder("Select frameworks")
    .search_placeholder("Search…")
badges-box := Combobox {
    items: root.many;
    multiple: true;
    trigger: ComboboxTrigger.badges;
    picked: [
        true,
        false,
        true,
        false,
        false,
        false,
        false,
        false,
        false,
        false
    ];
    placeholder: "Select frameworks";
    search-placeholder: "Search…";
}

#Maximum selections

Combobox::new(&state)
    .placeholder("Select up to 2 frameworks")
    .search_placeholder("Search…")
Combobox {
    items: root.many;
    multiple: true;
    max-selected: 2;
    picked: [
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false
    ];
    placeholder: "Select up to 2 frameworks";
    search-placeholder: "Search…";
}
// `max_selected(2)` draws the rows that would exceed it muted and stops the
// click, the Slint `at-capacity` block.
let maximum = body_box(combo(app, slot::MAXIMUM).placeholder("Select up to 2 frameworks"));

#Pinned items

Combobox::new(&state)
    .placeholder("Select framework...")
    .search_placeholder("Search…")
Combobox {
    placeholder: "Select framework...";
    search-placeholder: "Search…";
    items: [
        { label: "Next.js", value: "next", icon: "", group: "", badge: "", disabled: false, pinned: true },
        { label: "SvelteKit", value: "svelte", icon: "", group: "", badge: "", disabled: false, pinned: true },
        { label: "Nuxt.js", value: "nuxt", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Remix", value: "remix", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Astro", value: "astro", icon: "", group: "", badge: "", disabled: false, pinned: false },
    ];
}

#Rich items

Combobox::new(&state)
    .placeholder("Select framework...")
    .search_placeholder("Search…")
Combobox {
    placeholder: "Select framework...";
    search-placeholder: "Search…";
    items: [
        { label: "Next.js", value: "next", icon: "", group: "", badge: "Featured", disabled: false, pinned: false },
        { label: "SvelteKit", value: "svelte", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Nuxt.js", value: "nuxt", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Remix", value: "remix", icon: "", group: "", badge: "", disabled: false, pinned: false },
        { label: "Astro", value: "astro", icon: "", group: "", badge: "", disabled: false, pinned: false },
    ];
}

#Overflow

Combobox::new(&state)
    .placeholder("Select frameworks")
    .search_placeholder("Search…")
Combobox {
    items: root.many;
    multiple: true;
    trigger: ComboboxTrigger.overflow;
    picked: [
        true,
        false,
        true,
        false,
        false,
        true,
        false,
        false,
        true,
        true
    ];
    placeholder: "Select frameworks";
    search-placeholder: "Search…";
}

#Count

Combobox::new(&state)
    .placeholder("Select frameworks")
    .search_placeholder("Search…")
count-box := Combobox {
    items: root.many;
    multiple: true;
    trigger: ComboboxTrigger.count;
    picked: [
        true,
        true,
        true,
        true,
        true,
        true,
        false,
        false,
        false,
        false
    ];
    placeholder: "Select frameworks";
    search-placeholder: "Search…";
}

#Values

let basic = state.read(cx).selected_values();
let grouped = grouped.read(cx).selected_values();
VerticalLayout {
    spacing: 4px;
    Text {
        text: "basic: " + (default-box.current-label() == "" ? "none" : default-box.current-label());
        color: Theme.foreground;
        font-size: Theme.font-size-sm;
        font-family: Theme.mono-font-family;
    }
    Text {
        text: "grouped: " + grouped-box.current-label();
        color: Theme.foreground;
        font-size: Theme.font-size-sm;
        font-family: Theme.mono-font-family;
    }
    Text {
        text: "badges: " + (badges-box.current-label() == "" ? "none" : badges-box.current-label());
        color: Theme.foreground;
        font-size: Theme.font-size-sm;
        font-family: Theme.mono-font-family;
    }
    Text {
        text: "count: " + count-box.current-label();
        color: Theme.foreground;
        font-size: Theme.font-size-sm;
        font-family: Theme.mono-font-family;
    }
}
// The choice read back the way the trigger reads it: the single label, or
// the multiple one joined with commas — the Slint `current-label()`.
let values = body_box(
    column![
        muted_line(format!(
            "basic: {}",
            current_label(app, slot::BASIC).unwrap_or_else(|| "none".to_string())
        )),
        muted_line(format!(
            "multiple: {}",
            current_label(app, slot::MULTI).unwrap_or_else(|| "none".to_string())
        )),
        muted_line(format!(
            "maximum: {}",
            current_label(app, slot::MAXIMUM).unwrap_or_else(|| "none".to_string())
        )),
    ]
    .spacing(4.0),
);

#Sizes

Combobox::new(&state).large()
Combobox::new(&state)  // medium (default)
Combobox::new(&state).small()
SlintNo Slint version of this example yet.
// The size picks the height, the inset, the type and the chevron together,
// the way the Slint component's `size` picks `Theme.input-height`,
// `input-padding-x`, `input-font-size` and `icon-size`.
let sizes = body_box(
    column![
        combo(app, slot::SIZE_SMALL)
            .size(theme::ControlSize::Small)
            .placeholder("Small"),
        combo(app, slot::SIZE_MEDIUM).placeholder("Medium"),
        combo(app, slot::SIZE_LARGE)
            .size(theme::ControlSize::Large)
            .placeholder("Large"),
    ]
    .spacing(12.0),
);

#Cleanable

Combobox::new(&state).cleanable(true) // show clear button when a value is selected
SlintNo Slint version of this example yet.

#Disabled

Combobox::new(&state).disabled(true)
SlintNo Slint version of this example yet.
// A disabled combobox answers nothing: the trigger is drawn muted and the
// popover is given no open state, so the panel cannot be opened at all.
let disabled = body_box(
    combo(app, slot::DISABLED)
        .placeholder("Select framework...")
        .disabled(true),
);

#Events

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// `on_chosen` reports the row by index; the caller owns the items, asks
// itself what index 2 means, and decides whether to close by answering
// `open`. The line under the combobox is the report made visible.
let events = body_box(
    column![
        combo(app, slot::EVENTS).placeholder("Select framework..."),
        muted_line(format!(
            "Last choice: {}",
            label_of(app, slot::EVENTS, app.last[slot::EVENTS])
                .unwrap_or_else(|| "nothing yet".to_string())
        )),
    ]
    .spacing(8.0),
);

Both Change (fired on every toggle) and Confirm (fired when the dropdown closes) carry the full selection as Vec<Value>.

cx.subscribe_in(&state, window, |view, _, event, window, cx| {
    match event {
        ComboboxEvent::Change(values) => {
            // fired on every toggle
        }
        ComboboxEvent::Confirm(values) => {
            // fired when the dropdown closes
        }
    }
});
SlintNo Slint version of this example yet.

#Mutating Programmatically

Values are resolved through the current delegate. Values that cannot be found are ignored.

set_selected_values clears the search query first, so an active search never decides which values can be selected. Index paths address the list as it is currently displayed, so set_selected_indices, add_selected_index and remove_selected_index act on the visible rows and leave the query alone.

// Replace the entire selection by value
state.update(cx, |s, cx| {
    s.set_selected_values(&["React", "Angular"], window, cx);
});

// Replace the entire selection by index path
state.update(cx, |s, cx| {
    s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx);
});

// Add / remove individual items
state.update(cx, |s, cx| {
    s.add_selected_index(IndexPath::new(1), cx);
    s.remove_selected_index(IndexPath::new(0), cx);
});

// Clear all selections
state.update(cx, |s, cx| {
    s.clear_selection(cx);
});

// Read all selected values (multi-select)
let values = state.read(cx).selected_values(); // Vec<Value>

// Read the first selected value (single-select convenience)
let value = state.read(cx).selected_value(); // Option<Value>
SlintNo Slint version of this example yet.

#Keyboard Shortcuts

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// `↑`/`↓` step to the previous and next actionable row the query leaves
// visible, `Home`/`End` jump to the first and last, and `Enter` and `Space`
// choose the one highlighted. Escape is the popover's, and closes the panel
// from anywhere in it.
let keyboard = body_box(
    column![
        combo(app, slot::KEYBOARD).placeholder("Open with the keyboard"),
        muted_line("↑ ↓ Home End move the highlight; Enter or Space chooses; Escape closes."),
    ]
    .spacing(8.0),
);
KeyAction
TabFocus trigger
EnterOpen menu or confirm highlighted item
Up/DownNavigate options (opens menu if closed)
EscapeClose menu

#Theming

  • background — Dropdown input background
  • input — Trigger border color
  • foreground — Text color
  • muted_foreground — Placeholder and disabled text
  • border — Menu border
  • radius — Border radius