Combobox
A searchable input that filters a list of options. Type to narrow results, navigate with arrow keys, and select with Enter or click.
Selected: None
Installation
biji-ui = { version = "0.5.0", features = ["combobox"] }
Usage
use leptos::prelude::*;
use biji_ui::components::combobox;
#[component]
pub fn MyCombobox() -> impl IntoView {
view! {
<combobox::Root inline=true>
<combobox::InputTrigger
class="py-2 px-3 w-48 text-sm rounded-md border outline-none border-border bg-background"
placeholder="Search a fruit..."
/>
<combobox::Content class="overflow-hidden z-50 py-1 w-48 text-sm rounded-md border shadow-md border-border bg-background">
<combobox::Empty>
<div class="py-4 px-3 text-center text-muted-foreground">"No results."</div>
</combobox::Empty>
<combobox::Item
value="apple"
label="Apple"
class="py-1.5 px-3 cursor-default outline-none select-none data-[highlighted]:bg-accent"
>
"Apple"
</combobox::Item>
<combobox::Item
value="banana"
label="Banana"
class="py-1.5 px-3 cursor-default outline-none select-none data-[highlighted]:bg-accent"
>
"Banana"
</combobox::Item>
</combobox::Content>
</combobox::Root>
}
}
Button Trigger Variant
For a select-style trigger with a hidden search field that appears inside the dropdown, omit inline and use Trigger + Value + Input (inside Content) instead.
// Alternative: button trigger with search inside the dropdown
<combobox::Root>
<combobox::Trigger class="...">
<combobox::Value placeholder="Select a fruit..." />
"▾"
</combobox::Trigger>
<combobox::Content class="...">
<combobox::Input placeholder="Search..." class="..." />
<combobox::Item value="apple" label="Apple" class="...">
"Apple"
</combobox::Item>
</combobox::Content>
</combobox::Root>
Selected: None
Async / Server-side Search
Items are fetched from a Leptos #[server] function. The example splits responsibilities into two components: AsyncQueryManager owns all reactive state and is mounted outside Content, and ItemDisplay is a pure render component placed inside Content.
The query is debounced 300 ms before triggering a new call. An on_loading_change callback fires true when a fetch starts and false when it settles, so the parent can render a spinner anywhere.
Infinite scroll is handled by an IntersectionObserver on a sentinel element at the bottom of the list. When the sentinel becomes visible the next page is fetched and appended without re-rendering existing items.
⚠ SSR + Hydrate: avoid Resource::new and LocalResource inside combobox
Resource::new and LocalResource register with the nearest <Suspense> / <Transition> boundary in the tree. When the resource becomes pending (e.g. on a new search keystroke), the boundary shows its fallback — causing a full-page blank flash if you have an AuthGuard or similar layout wrapper using <Transition>.
Use spawn_local with a generation counter instead. It is a detached async task with no knowledge of the reactive resource system and therefore never triggers any Suspense boundary. See the code sample below.
💡 Lift async state outside Content
combobox::Content unmounts its children after the hide animation completes. Placing Effects or Resources inside Content means recreating them on every open cycle, which causes a WASM block and a blank frame. Mount an AsyncQueryManager component alongside — not inside — Content so signals and Effects are created exactly once.
// Returns one extra item to detect whether more pages exist.
#[server]
pub async fn search_countries(
query: String,
page: u32,
per_page: u32,
) -> Result<(Vec<(String, String)>, bool), ServerFnError> {
let countries: &[(&str, &str)] = &[("de", "Germany"), ("fr", "France") /* … */];
let q = query.to_lowercase();
let skip = (page * per_page) as usize;
let mut fetched: Vec<_> = countries
.iter()
.filter(|(_, n)| q.is_empty() || n.to_lowercase().contains(&q))
.skip(skip)
.take(per_page as usize + 1) // one extra → has_more signal
.collect();
let has_more = fetched.len() > per_page as usize;
fetched.truncate(per_page as usize);
Ok((fetched.into_iter().map(|(v, l)| (v.to_string(), l.to_string())).collect(), has_more))
}
// ── AsyncQueryManager ────────────────────────────────────────────────────────
//
// Manages debounced search and async fetching. Rendered OUTSIDE
// combobox::Content so it is never unmounted when the dropdown closes.
//
// combobox::Content uses an animated show/hide: it unmounts its children
// after the hide animation completes. Putting Effects or Resources inside
// Content means recreating them on every open cycle — WASM block → blank frame.
// By keeping this component outside Content, signals and Effects are created
// exactly once for the lifetime of the parent.
//
// spawn_local instead of Resource::new
// ─────────────────────────────────────
// Resource::new (and LocalResource) register with the nearest Suspense /
// Transition boundary in the tree. When the resource becomes pending (e.g.
// on a new search), a parent <Transition> sees a pending resource and briefly
// shows its fallback — a blank screen flash. This is especially noticeable
// in apps that wrap routes with an AuthGuard <Transition>.
// spawn_local is a detached async task: it has no knowledge of the reactive
// resource system and therefore never triggers any Suspense boundary.
#[component]
fn AsyncQueryManager(
query_and_page: RwSignal<(String, u32)>,
items: RwSignal<Vec<(String, String)>>,
has_more: RwSignal<bool>,
show_empty: RwSignal<bool>,
is_loading: RwSignal<bool>,
is_fetching: RwSignal<bool>,
on_loading_change: Callback<bool>,
) -> impl IntoView {
use biji_ui::components::combobox::ComboboxState;
use leptos::reactive::spawn_local;
use leptos_use::use_debounce_fn_with_arg;
let ctx = expect_context::<ComboboxState>();
let set_query = use_debounce_fn_with_arg(
move |q: String| {
// Skip if the query hasn't changed (avoids spurious reset at t=300ms).
if q == query_and_page.get_untracked().0 { return; }
batch(|| {
query_and_page.set((q, 0));
has_more.set(false);
show_empty.set(false);
is_loading.set(true);
on_loading_change.run(true);
});
},
300.0,
);
Effect::new(move |_| { set_query(ctx.query.get()); });
// Generation counter: when a new fetch starts, any in-flight response
// with an older sequence number is discarded. Prevents stale results
// overwriting newer ones when the query changes quickly.
let fetch_gen = RwSignal::new(0u64);
// Signal loading on mount — the first fetch fires immediately.
on_loading_change.run(true);
Effect::new(move |_| {
let (q, page) = query_and_page.get();
let fetch_seq = fetch_gen.get_untracked() + 1;
fetch_gen.set(fetch_seq);
spawn_local(async move {
let result = search_countries(q, page, 8).await;
// Discard if a newer request has already started.
if fetch_gen.get_untracked() != fetch_seq { return; }
match result {
Ok((page_items, more)) => {
let is_empty = page_items.is_empty();
batch(|| {
if page == 0 {
items.set(page_items);
show_empty.set(is_empty);
} else {
items.update(|v| v.extend(page_items));
}
has_more.set(more);
is_fetching.set(false);
is_loading.set(false);
on_loading_change.run(false);
});
}
Err(_) => {
batch(|| {
if page == 0 { items.set(vec![]); }
has_more.set(false);
show_empty.set(false);
is_fetching.set(false);
is_loading.set(false);
on_loading_change.run(false);
});
}
}
});
});
}
// ── ItemDisplay ───────────────────────────────────────────────────────────────
//
// Pure rendering — no Effects, no Resources, no Signals created here.
// Lives inside combobox::Content so it remounts on every open, but
// because it only creates DOM nodes + event listeners the cost is negligible.
#[component]
fn ItemDisplay(
items: RwSignal<Vec<(String, String)>>,
is_loading: RwSignal<bool>,
show_empty: RwSignal<bool>,
has_more: RwSignal<bool>,
load_more: Callback<()>,
) -> impl IntoView {
use leptos::html;
use leptos_use::use_intersection_observer;
let sentinel_ref = NodeRef::<html::Div>::new();
use_intersection_observer(sentinel_ref, move |entries, _| {
if entries.first().map(|e| e.is_intersecting()).unwrap_or(false) {
load_more.run(());
}
});
const ITEM_CLS: &str = "flex items-center px-3 py-1.5 cursor-default select-none outline-none \
hover:bg-accent hover:text-accent-foreground \
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground \
data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
view! {
<div class="overflow-y-auto py-1 max-h-60">
<Show when={move || is_loading.get() && items.with(|v| v.is_empty())}>
<div class="flex justify-center py-4">
<div class="w-4 h-4 rounded-full border-2 animate-spin border-muted-foreground border-t-transparent" />
</div>
</Show>
<Show when={move || show_empty.get()}>
<div class="py-4 px-3 text-sm text-center text-muted-foreground">
"No countries found."
</div>
</Show>
<For
each={move || items.get()}
key={|(value, _)| value.clone()}
children={move |(value, label)| {
view! {
<combobox::Item value={value} label={label.clone()} class={ITEM_CLS}>
<span class="flex-1">{label}</span>
<combobox::ItemIndicator>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</combobox::ItemIndicator>
</combobox::Item>
}
}}
/>
<div
node_ref={sentinel_ref}
class={move || if has_more.get() { "py-2 flex justify-center text-muted-foreground" } else { "hidden" }}
>
<div class="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
</div>
}
}
// ── MyAsyncCombobox ───────────────────────────────────────────────────────────
//
// All async state lives here — outside combobox::Content — so signals and
// Effects are created once and persist across open/close cycles.
#[component]
pub fn MyAsyncCombobox() -> impl IntoView {
let query_and_page = RwSignal::<(String, u32)>::new((String::new(), 0));
let items = RwSignal::<Vec<(String, String)>>::new(vec![]);
let has_more = RwSignal::new(false);
let show_empty = RwSignal::new(false);
let is_loading = RwSignal::new(true);
let is_fetching = RwSignal::new(false);
let trigger_is_loading = RwSignal::new(false);
let load_more = Callback::new(move |_: ()| {
if is_fetching.get_untracked() || !has_more.get_untracked() { return; }
is_fetching.set(true);
query_and_page.update(|(_, p)| *p += 1);
});
view! {
<combobox::RootWith inline=true let:_c>
// Always-mounted query manager — outside Content, never torn down.
<AsyncQueryManager
query_and_page=query_and_page
items=items
has_more=has_more
show_empty=show_empty
is_loading=is_loading
is_fetching=is_fetching
on_loading_change=Callback::new(move |v| trigger_is_loading.set(v))
/>
<div class="relative">
<combobox::InputTrigger class="…" placeholder="Search countries…" />
<Show when=move || trigger_is_loading.get()>
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
<div class="w-3.5 h-3.5 rounded-full border-2 animate-spin border-muted-foreground border-t-transparent" />
</div>
</Show>
</div>
<combobox::Content class="…"
show_class="opacity-100 scale-100"
hide_class="opacity-0 scale-95"
>
// Cheap display — no reactive setup on mount, just DOM nodes.
<ItemDisplay
items=items
is_loading=is_loading
show_empty=show_empty
has_more=has_more
load_more=load_more
/>
</combobox::Content>
</combobox::RootWith>
}
}
Selected: None
RootWith
Use RootWith to access ComboboxState inline via the let: binding. The state exposes open, value, and query as reactive signals.
Nothing selected
use leptos::prelude::*;
use biji_ui::components::combobox;
#[component]
pub fn MyCombobox() -> impl IntoView {
view! {
<combobox::RootWith let:c>
<p class="text-sm text-muted-foreground">
{move || {
c.value.get()
.map(|v| format!("Selected: {v}"))
.unwrap_or_else(|| "Nothing selected".to_string())
}}
</p>
<combobox::Trigger class="...">
<combobox::Value placeholder="Select a fruit..." />
</combobox::Trigger>
<combobox::Content class="..." show_class="..." hide_class="...">
<combobox::Input placeholder="Search..." class="..." />
<combobox::Item value="apple" class="...">"Apple"</combobox::Item>
<combobox::Item value="banana" class="...">"Banana"</combobox::Item>
</combobox::Content>
</combobox::RootWith>
}
}
API Reference
Root / RootWith
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the root wrapper element. |
| value | Option<String> | None | The initially selected value. |
| positioning | Positioning | BottomStart | Where the dropdown panel appears relative to the trigger. |
| hide_delay | Duration | 200ms | Delay before the panel is removed from the DOM after closing. |
| avoid_collisions | AvoidCollisions | Flip | Strategy used to keep the panel within the viewport. |
| on_value_change | Option<Callback<String>> | None | Fired with the selected value when selection changes. |
| inline | bool | false | Set to true when using InputTrigger (the input sits above the dropdown). |
InputTrigger
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the inline input trigger. |
| placeholder | String | "" | Placeholder text shown when nothing is selected. |
Trigger
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the trigger button. |
Value
| Name | Type | Default | Description |
|---|---|---|---|
| placeholder | String | "" | Text shown when no item is selected. |
Content
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the floating dropdown panel. |
| show_class | String | "" | Extra class added while the panel is visible. |
| hide_class | String | "" | Extra class added while the panel is animating out. |
Input
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the search input. |
| placeholder | String | "" | Placeholder text for the search input. |
Item
| Name | Type | Default | Description |
|---|---|---|---|
| value | String | — | The value submitted when this item is selected. |
| label | Option<String> | value | Display label used for filtering and shown in the trigger. Defaults to value. |
| class | String | "" | CSS class applied to the item element. |
| disabled | bool | false | When true, the item cannot be selected. |
Data Attributes
| Attribute | Description |
|---|---|
| data-state | "open" or "closed". Present on Trigger. |
| data-state | "checked" or "unchecked". Present on Item. |
| data-highlighted | Present on Item when it has keyboard focus or the mouse is over it. |
| data-disabled | Present on Item when it is disabled. |
Keyboard Navigation
| Key | Description |
|---|---|
| ArrowDown | Moves focus to the next visible item. |
| ArrowUp | Moves focus to the previous visible item. |
| Home | Moves focus to the first visible item. |
| End | Moves focus to the last visible item. |
| Enter | Selects the focused item and closes the dropdown. |
| Escape | Closes the dropdown and returns focus to the trigger. |
| Tab | Closes the dropdown. |