Biji UI

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

NameTypeDefaultDescription
classString""CSS class applied to the root wrapper element.
valueOption<String>NoneThe initially selected value.
positioningPositioningBottomStartWhere the dropdown panel appears relative to the trigger.
hide_delayDuration200msDelay before the panel is removed from the DOM after closing.
avoid_collisionsAvoidCollisionsFlipStrategy used to keep the panel within the viewport.
on_value_changeOption<Callback<String>>NoneFired with the selected value when selection changes.
inlineboolfalseSet to true when using InputTrigger (the input sits above the dropdown).

InputTrigger

NameTypeDefaultDescription
classString""CSS class applied to the inline input trigger.
placeholderString""Placeholder text shown when nothing is selected.

Trigger

NameTypeDefaultDescription
classString""CSS class applied to the trigger button.

Value

NameTypeDefaultDescription
placeholderString""Text shown when no item is selected.

Content

NameTypeDefaultDescription
classString""CSS class applied to the floating dropdown panel.
show_classString""Extra class added while the panel is visible.
hide_classString""Extra class added while the panel is animating out.

Input

NameTypeDefaultDescription
classString""CSS class applied to the search input.
placeholderString""Placeholder text for the search input.

Item

NameTypeDefaultDescription
valueStringThe value submitted when this item is selected.
labelOption<String>valueDisplay label used for filtering and shown in the trigger. Defaults to value.
classString""CSS class applied to the item element.
disabledboolfalseWhen true, the item cannot be selected.

Data Attributes

AttributeDescription
data-state"open" or "closed". Present on Trigger.
data-state"checked" or "unchecked". Present on Item.
data-highlightedPresent on Item when it has keyboard focus or the mouse is over it.
data-disabledPresent on Item when it is disabled.

Keyboard Navigation

KeyDescription
ArrowDownMoves focus to the next visible item.
ArrowUpMoves focus to the previous visible item.
HomeMoves focus to the first visible item.
EndMoves focus to the last visible item.
EnterSelects the focused item and closes the dropdown.
EscapeCloses the dropdown and returns focus to the trigger.
TabCloses the dropdown.