Multi Select for Slint
KitMultiSelect · Slint Kit 0.3.0
Multiple-choice selector with removable chips for accepted values.

Try the component
Download the complete project, extract it and run cargo run --locked in its folder. Rust 1.92+; Slint Kit 0.3.0 from crates.io. You can also copy the four files below.
Choose Push to add it without losing Email. Remove a chip or toggle an accepted channel to remove it.
ui/app.slint
export { KitFormValidation, HtmlInputKind, NumberInputFormat, KitInputConstraints, KitValidationResult, KitValidityState } from "@kit/kit.slint";
// SPDX-License-Identifier: GPL-3.0-or-later
import { KitMultiSelect, OptionRow, ChoiceRow, ChipRow, SelectState, NavigationStep, SelectionAvailability, KitTheme, KitThemes } from "@kit/kit.slint";
export component App inherits Window {
title: "KitMultiSelect example";
preferred-width: 520px;
preferred-height: 400px;
max-width: 100000px;
max-height: 100000px;
background: root.theme.canvas;
in-out property <KitTheme> theme: KitThemes.light;
in property <[OptionRow]> rows;
in property <[ChoiceRow]> choices;
in property <[ChipRow]> chips;
in property <SelectState> select-state;
in property <string> result;
callback toggle(int, int);
callback navigate(NavigationStep);
callback open();
callback close();
callback remove(int);
callback query(string);
callback typeahead(string);
VerticalLayout {
padding: 24px;
spacing: 16px;
alignment: start;
control := KitMultiSelect {
theme: root.theme; rows: root.rows; chips: root.chips; state: root.select-state; placeholder: "Add a channel";
open-requested => { root.open(); }
navigate(step) => { root.navigate(step); }
activate(token, generation) => { root.toggle(token, generation); }
dismiss(reason) => { root.close(); }
tab-navigation(reverse) => { return reject; }
query-edited(text) => { root.query(text); }
typeahead(text) => { root.typeahead(text); }
remove-requested(token) => { root.remove(token); }
}
result := Text { text: root.result; color: root.theme.text-primary; }
}
}
src/main.rs
// SPDX-License-Identifier: GPL-3.0-or-later
use slint::{ComponentHandle, ModelRc, VecModel};
use slint_kit::{MultiSelectController, Ownership, SelectOption, NavigationStep};
use std::{cell::RefCell, rc::Rc};
mod ui { slint::include_modules!(); }
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Channel { Email, Push, Sms }
impl Channel {
fn all() -> [Self; 3] { [Self::Email, Self::Push, Self::Sms] }
fn token(self) -> i32 { match self { Self::Email => 11, Self::Push => 23, Self::Sms => 38 } }
fn from_token(token: i32) -> Option<Self> { Self::all().into_iter().find(|key| key.token() == token) }
fn label(self) -> &'static str { match self { Self::Email => "Email", Self::Push => "Push", Self::Sms => "SMS" } }
}
fn publish(app: &ui::App, controller: &MultiSelectController<Channel>, open: bool) {
let visible = controller.options().iter().map(|option| option.key).collect::<Vec<_>>();
let rows = visible.iter().copied().map(|key| ui::OptionRow { token: key.token(), generation: 1, label: key.label().into(), description: "".into(), disabled: false });
let choices = Channel::all().map(|key| ui::ChoiceRow { token: key.token(), label: key.label().into(), disabled: false, selected: controller.selected().contains(&key) });
let chips = controller.selected().iter().map(|key| ui::ChipRow { token: key.token(), label: key.label().into(), removable: true }).collect::<Vec<_>>();
let active = controller.active().copied().unwrap_or(Channel::Email);
app.set_rows(ModelRc::new(VecModel::from(rows.collect::<Vec<_>>()))); app.set_choices(ModelRc::new(VecModel::from(choices.to_vec()))); app.set_chips(ModelRc::new(VecModel::from(chips)));
app.set_select_state(ui::SelectState { open, generation: 1, active_index: visible.iter().position(|key| *key == active).map_or(-1, |index| index as i32), active: ui::SelectionRef { present: controller.active().is_some(), token: active.token() }, selected: ui::SelectionRef { present: false, token: -1 }, selection_availability: ui::SelectionAvailability::Absent });
app.set_result(format!("Selected: {}", controller.selected().iter().map(|key| key.label()).collect::<Vec<_>>().join(", ")).into());
}
fn step(value: ui::NavigationStep) -> NavigationStep { match value { ui::NavigationStep::Next => NavigationStep::Next, ui::NavigationStep::Previous => NavigationStep::Previous, ui::NavigationStep::First => NavigationStep::First, ui::NavigationStep::Last => NavigationStep::Last, ui::NavigationStep::PageNext => NavigationStep::PageNext, ui::NavigationStep::PagePrevious => NavigationStep::PagePrevious } }
fn main() -> Result<(), slint::PlatformError> {
let app = ui::App::new()?;
slint_kit::init_form_validation!(ui, app);
let controller = Rc::new(RefCell::new(MultiSelectController::new(Channel::all().into_iter().map(|key| SelectOption::new(key, key.label())).collect(), vec![Channel::Email], Ownership::Local, None).expect("unique channel keys")));
controller.borrow_mut().navigate(NavigationStep::First, 3);
publish(&app, &controller.borrow(), false);
let state = controller.clone(); let weak = app.as_weak();
app.on_open(move || { if let Some(app) = weak.upgrade() { publish(&app, &state.borrow(), true); } });
let state = controller.clone(); let weak = app.as_weak();
app.on_close(move || { if let Some(app) = weak.upgrade() { publish(&app, &state.borrow(), false); } });
let state = controller.clone(); let weak = app.as_weak();
app.on_navigate(move |direction| { state.borrow_mut().navigate(step(direction), 3); if let Some(app) = weak.upgrade() { publish(&app, &state.borrow(), app.get_select_state().open); } });
let state = controller.clone(); let weak = app.as_weak();
app.on_toggle(move |token, generation| {
if generation != 1 { return; }
if let Some(key) = Channel::from_token(token) { let _ = state.borrow_mut().toggle(&key); }
if let Some(app) = weak.upgrade() { publish(&app, &state.borrow(), true); }
});
let state = controller.clone(); let weak = app.as_weak();
let query = Rc::new(RefCell::new(String::new()));
let draft = query.clone();
app.on_query(move |text| {
*draft.borrow_mut() = text.to_string();
let needle = text.trim().to_lowercase();
let options = Channel::all().into_iter().filter(|key| key.label().to_lowercase().contains(&needle)).map(|key| SelectOption::new(key, key.label())).collect();
let mut controller = state.borrow_mut();
controller.set_options(options).expect("unique channel keys");
controller.navigate(NavigationStep::First, 3);
if let Some(app) = weak.upgrade() { publish(&app, &controller, true); }
});
let weak = app.as_weak();
app.on_typeahead(move |text| {
query.borrow_mut().push_str(&text);
let text = query.borrow().clone();
if let Some(app) = weak.upgrade() { app.invoke_query(text.into()); }
});
let weak = app.as_weak();
app.on_remove(move |token| {
if let Some(key) = Channel::from_token(token) { let accepted = controller.borrow().selected().iter().copied().filter(|selected| *selected != key).collect(); let _ = controller.borrow_mut().set_selected(accepted); }
if let Some(app) = weak.upgrade() { publish(&app, &controller.borrow(), app.get_select_state().open); }
});
app.run()
}
Cargo.toml and build.rs
Cargo.toml
[package]
name = "slint-kit-multi-select-example"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
license = "GPL-3.0-or-later"
publish = false
[dependencies]
slint-kit = "=0.3.0"
slint = { version = "=1.18.1", default-features = false, features = ["std", "compat-1-18", "backend-winit", "renderer-skia", "accessibility"] }
[build-dependencies]
slint-kit-build = "=0.3.0"
[workspace]
build.rs
fn main() { slint_kit_build::compile("ui/app.slint").expect("compile component example"); }
Files: Cargo.toml · build.rs · ui/app.slint · src/main.rs
Project MultiSelectController options, accepted chips and SelectState. Handle activate and remove-requested separately; connect query-edited and typeahead to filtering without replacing the accepted set.
The query does not replace the accepted choice set. In read-only mode values appear in selectable native fields and changes are blocked.
Visual composition
The file below produces the screenshot. For controller-driven components, it shows the displayed state without supplying the Rust wiring. Create a project.
Show the screenshot source

// SPDX-License-Identifier: GPL-3.0-or-later
import { KitMultiSelect, KitTheme, KitThemes, SelectionAvailability } from "@kit/kit.slint";
export component Example inherits Window {
title: "KitMultiSelect example";
preferred-width: 420px;
preferred-height: 220px;
max-width: 100000px;
max-height: 100000px;
background: root.theme.canvas;
in-out property <KitTheme> theme: KitThemes.light;
in-out property <string> status;
VerticalLayout {
padding: 24px; spacing: 16px; alignment: start;
Text { text: "Multi Select"; color: root.theme.text-primary; font-size: 20px * root.theme.text-scale; font-weight: 600; }
KitMultiSelect { width: 372px; theme: root.theme; rows: [{ token: 11, generation: 1, label: "Overview", description: "Project summary", disabled: false }, { token: 23, generation: 1, label: "Activity", description: "Recent changes", disabled: false }, { token: 38, generation: 1, label: "Settings", description: "Project preferences", disabled: false }]; chips: [{ token: 11, label: "Design", removable: true }]; state: { open: false, generation: 1, active-index: 0, active: { present: true, token: 11 }, selected: { present: true, token: 11 }, selection-availability: SelectionAvailability.available }; }
if root.status != "": Text { text: root.status; color: root.theme.text-secondary; font-size: 12px * root.theme.text-scale; wrap: word-wrap; }
}
}
Public API
Inherits Rectangle.
Properties
| Name | Type | Direction |
|---|---|---|
required |
bool |
in |
required-message |
string |
in |
custom-validity |
string |
in-out |
validity |
KitValidityState |
out |
will-validate |
bool |
out |
validation-message |
string |
out |
theme |
KitTheme |
in |
control-size |
ControlSize |
in |
access |
FieldAccess |
in |
rows |
[OptionRow] |
in |
chips |
[ChipRow] |
in |
state |
SelectState |
in |
placeholder |
string |
in |
Types and variants
| Type | Declaration |
|---|---|
struct ChipRow |
token: int, label: string, removable: bool |
enum CloseReason |
escape, outside-press, system-back, trigger-toggle, programmatic, host-closed |
enum ControlSize |
compact, medium, comfortable |
enum FieldAccess |
editable, read-only, disabled |
struct KitValidityState |
valid: bool, value-missing: bool, type-mismatch: bool, pattern-mismatch: bool, too-long: bool, too-short: bool, range-underflow: bool, range-overflow: bool, step-mismatch: bool, bad-input: bool, custom-error: bool, |
enum NavigationStep |
next, previous, first, last, page-next, page-previous |
struct OptionRow |
token: int, generation: int, label: string, description: string, disabled: bool |
enum SelectionAvailability |
absent, available, unavailable |
struct SelectionRef |
present: bool, token: int |
struct SelectState |
selection-availability: SelectionAvailability, selected: SelectionRef, active: SelectionRef, open: bool, active-index: int, generation: int |
Callbacks
invalid()open-requested()navigate(NavigationStep)activate(int, int)dismiss(CloseReason)typeahead(string)tab-navigation(bool) → EventResultquery-edited(string)remove-requested(int)
Functions
check-validity() → boolreport-validity() → boolset-custom-validity(message: string)reset-validation()take-focus()