Skip to content

Searchable Select

The SearchableSelect component creates a server-side searchable dropdown using HTMX, replacing client-side libraries like Select2 or Choices.js with pure server-side filtering. Perfect for large datasets and dynamic options.

Goal

By the end of this guide, you'll be able to create searchable dropdowns with server-side filtering and zero JavaScript dependencies.


Quick Start

Here's the simplest way to create a searchable select.

Live Preview
SearchableSelect(
    endpoint="/api/users/search",
    name="user_id",
    placeholder="Search users...",
    csp_safe=True,
)

Visual Examples & Use Cases

1. User Selection

Search and select from large user lists.

FormGroup(
    SearchableSelect(
        endpoint="/api/users/search",
        name="assigned_to",
        select_id="assigned_to",
        placeholder="Search by name or email...",
        debounce=300,
        csp_safe=True
    ),
    label="Assign To",
    help_text="Start typing to search users"
)

# Server endpoint
@app.get("/api/users/search")
def search_users(q: str = ""):
    if len(q) < 2:
        return P("Type at least 2 characters", cls="text-muted p-2")

    users = db.query(User).filter(
        or_(
            User.name.ilike(f"%{q}%"),
            User.email.ilike(f"%{q}%")
        )
    ).limit(10).all()

    if not users:
        return P("No users found", cls="text-muted p-2")

    return Div(*[
        A(
            Div(
                Strong(user.name),
                Br(),
                Small(user.email, cls="text-muted")
            ),
            href="#",
            cls="list-group-item list-group-item-action",
            data_fs_searchable_option=True,
            data_fs_select_id="assigned_to",
            data_fs_input_id="assigned_to-input",
            data_fs_results_id="assigned_to-results",
            data_fs_value=user.id,
            data_fs_label=user.name
        )
        for user in users
    ])

CSP-safe mode

For production apps, prefer:

SearchableSelect(
    endpoint="/api/search",
    name="user_id",
    select_id="user_id",
    csp_safe=True,
)

csp_safe=True avoids inline click handlers so the component works with a strict Content Security Policy. In this mode, server-rendered result links should include the data-fs-* attributes Faststrap expects.

2. Country/Location Selection

Searchable location picker.

SearchableSelect(
    endpoint="/api/countries/search",
    name="country",
    placeholder="Search countries...",
    initial_options=[
        ("us", "United States"),
        ("uk", "United Kingdom"),
        ("ca", "Canada"),
    ]
)

@app.get("/api/countries/search")
def search_countries(q: str = ""):
    countries = get_countries()  # Your data source

    if q:
        countries = [c for c in countries if q.lower() in c.name.lower()]

    return Div(*[
        A(
            country.name,
            href="#",
            cls="list-group-item list-group-item-action",
            data_value=country.code
        )
        for country in countries[:20]  # Limit results
    ])

E-commerce product selection.

SearchableSelect(
    endpoint="/api/products/search",
    name="product_id",
    placeholder="Search products...",
    min_chars=3,
    debounce=400
)

@app.get("/api/products/search")
def search_products(q: str = ""):
    if len(q) < 3:
        return ""

    products = db.query(Product).filter(
        Product.name.ilike(f"%{q}%")
    ).limit(15).all()

    return Div(*[
        A(
            Div(
                Img(src=p.image, style="width: 40px; height: 40px; object-fit: cover;", cls="me-2"),
                Div(
                    Strong(p.name),
                    Br(),
                    Small(f"${p.price}", cls="text-success")
                ),
                cls="d-flex align-items-center"
            ),
            href="#",
            cls="list-group-item list-group-item-action"
        )
        for p in products
    ])

Practical Functionality

With Initial Options

Show popular choices before search.

SearchableSelect(
    endpoint="/api/tags/search",
    name="tags",
    placeholder="Search tags...",
    csp_safe=True,
    initial_options=[
        ("python", "Python"),
        ("javascript", "JavaScript"),
        ("react", "React"),
        ("fasthtml", "FastHTML"),
    ]
)

Custom Debounce

Adjust search delay for different use cases.

# Fast search for small datasets
SearchableSelect(
    endpoint="/api/quick-search",
    name="item",
    csp_safe=True,
    debounce=150  # 150ms delay
)

# Slower search for expensive queries
SearchableSelect(
    endpoint="/api/heavy-search",
    name="item",
    csp_safe=True,
    debounce=500  # 500ms delay
)

Minimum Characters

Prevent searches that are too broad.

SearchableSelect(
    endpoint="/api/search",
    name="query",
    min_chars=3,  # Require 3+ characters
    placeholder="Type at least 3 characters...",
    csp_safe=True,
)

@app.get("/api/search")
def search(q: str = ""):
    if len(q) < 3:
        return P(
            "Please enter at least 3 characters",
            cls="text-muted p-3 text-center"
        )

    # Perform search...

Integration Patterns

With Form Submission

Form(
    FormGroup(
        SearchableSelect(
            endpoint="/api/users/search",
            name="user_id",
            placeholder="Select user..."
        ),
        label="User",
        required=True
    ),
    FormGroup(
        Input(name="message"),
        label="Message"
    ),
    Button("Send", type="submit"),
    hx_post="/messages/send"
)

With Loading Indicator

Div(
    SearchableSelect(
        endpoint="/api/search",
        name="item"
    ),
    Div(
        Spinner(size="sm"),
        " Searching...",
        cls="htmx-indicator text-muted mt-2"
    )
)

Multi-Select Pattern

Allow selecting multiple items.

def MultiSearchableSelect(endpoint, name):
    return Div(
        SearchableSelect(
            endpoint=endpoint,
            name=f"{name}_search"
        ),
        Div(id=f"{name}-selected", cls="mt-2"),
        # Hidden inputs for selected values
        Div(id=f"{name}-values")
    )

# When user clicks result
@app.post("/select/{item_id}")
def select_item(item_id: int):
    item = get_item(item_id)

    return Div(
        # Add to selected list
        Badge(
            item.name,
            Icon("x", cls="ms-1"),
            cls="me-1",
            hx_delete=f"/deselect/{item_id}",
            hx_target="closest .badge",
            hx_swap="outerHTML"
        ),
        # Add hidden input
        Input("items[]", input_type="hidden", value=item_id),
        hx_swap_oob="beforeend:#selected-items"
    )

Parameter Reference

Parameter Type Default Description
endpoint str Required Server endpoint for search (receives 'q' param)
name str Required Form field name
placeholder str "Search..." Search input placeholder
initial_options list[tuple] None Initial options as (value, text) tuples
debounce int 300 Milliseconds to wait after typing
min_chars int 2 Minimum characters before searching
select_id str \| None Auto Unique ID for the select element
csp_safe bool \| None None Recommended production mode that avoids inline JavaScript
**kwargs Any - Additional HTML attributes

Best Practices

✅ Do This

# Limit results to prevent overwhelming UI
@app.get("/api/search")
def search(q: str):
    results = query.filter(...).limit(20).all()  # Max 20
    return render_results(results)

# Show helpful empty states
if not results:
    return P("No results found. Try different keywords.", cls="text-muted p-3")

# Use debounce to reduce server load
SearchableSelect(
    endpoint="/api/search",
    name="item",
    csp_safe=True,
    debounce=300  # Wait for user to finish typing
)

# Provide visual feedback
return Div(
    Icon("search", cls="text-muted me-2"),
    "Searching...",
    cls="htmx-indicator"
)

❌ Don't Do This

# Don't return unlimited results
@app.get("/api/search")
def search(q: str):
    return query.all()  # Could be thousands!

# Don't search on every keystroke
SearchableSelect(
    endpoint="/api/search",
    name="item",
    debounce=0  # Server overload!
)

# Don't forget minimum characters
@app.get("/api/search")
def search(q: str):
    # Searching for "a" returns everything!
    return query.filter(name.ilike(f"%{q}%")).all()

Complete Example

Full searchable user selector.

from fasthtml.common import *
from faststrap import SearchableSelect, FormGroup, Button

@app.get("/assign-task")
def assign_task_form():
    return Form(
        FormGroup(
            SearchableSelect(
                endpoint="/api/users/search",
                name="assigned_to",
                select_id="assigned_to",
                placeholder="Search by name or email...",
                debounce=300,
                csp_safe=True
            ),
            label="Assign To",
            help_text="Start typing to search users",
            required=True
        ),
        FormGroup(
            Input(name="task_name"),
            label="Task Name",
            required=True
        ),
        Button("Assign Task", type="submit", variant="primary"),
        hx_post="/tasks/assign"
    )

@app.get("/api/users/search")
def search_users(q: str = ""):
    # Require minimum characters
    if len(q) < 2:
        return P(
            Icon("search", cls="me-2"),
            "Type at least 2 characters to search",
            cls="text-muted p-3 text-center"
        )

    # Search users
    users = db.query(User).filter(
        or_(
            User.name.ilike(f"%{q}%"),
            User.email.ilike(f"%{q}%")
        )
    ).limit(15).all()

    # Handle empty results
    if not users:
        return P(
            Icon("inbox", cls="me-2"),
            "No users found",
            cls="text-muted p-3 text-center"
        )

    # Return results
    return Div(*[
        A(
            Div(
                Img(
                    src=user.avatar or "/static/default-avatar.png",
                    cls="rounded-circle me-2",
                    style="width: 32px; height: 32px; object-fit: cover;"
                ),
                Div(
                    Strong(user.name),
                    Br(),
                    Small(user.email, cls="text-muted")
                ),
                cls="d-flex align-items-center"
            ),
            href="#",
            cls="list-group-item list-group-item-action",
            data_fs_searchable_option=True,
            data_fs_select_id="assigned_to",
            data_fs_input_id="assigned_to-input",
            data_fs_results_id="assigned_to-results",
            data_fs_value=user.id,
            data_fs_label=user.name
        )
        for user in users
    ])

faststrap.components.forms.searchable_select.SearchableSelect(endpoint, name, placeholder='Search...', min_chars=2, debounce=300, initial_options=None, required=False, size=None, select_id=None, csp_safe=None, **kwargs)

Server-side searchable dropdown using HTMX.

Replaces client-side libraries like Select2/Choices.js with pure server-side filtering. As user types, sends requests to server which returns filtered options.

Parameters:

Name Type Description Default
endpoint str

Server endpoint for search (receives 'q' query param)

required
name str

Form field name

required
placeholder str

Search input placeholder

'Search...'
initial_options list[tuple[str, str]] | None

Initial options as list of (value, text) tuples

None
debounce int

Milliseconds to wait after typing before searching

300
min_chars int

Minimum characters before triggering search

2
select_id str | None

Unique ID for the select element

None
csp_safe bool | None

Avoid inline click handlers by using delegated listeners from FastStrap init script. None preserves the legacy default for backward compatibility and emits a deprecation warning.

None
**kwargs Any

Additional HTML attributes

{}

Returns:

Type Description
Div

Div containing search input and select dropdown

Example

Basic usage:

SearchableSelect( ... endpoint="/api/users/search", ... name="user_id", ... placeholder="Search users..." ... )

With initial options:

SearchableSelect( ... endpoint="/api/countries/search", ... name="country", ... initial_options=[ ... ("us", "United States"), ... ("uk", "United Kingdom"), ... ] ... )

Server-side handler:

@app.get("/api/users/search")
def search_users(q: str = ""):
    if len(q) < 2:
        return ""

    users = db.query(User).filter(
        User.name.ilike(f"%{q}%")
    ).limit(10).all()

    return Div(*[
        A(
            user.name,
            href="#",
            cls="list-group-item list-group-item-action",
            data_value=user.id,
        )
        for user in users
    ])

Note

The server endpoint should: 1. Receive 'q' query parameter with search term 2. Filter results server-side 3. Return HTML options to replace the results container

For better UX, consider: - Showing loading indicator during search - Handling empty results gracefully - Limiting results to prevent overwhelming the UI

Source code in src/faststrap/components/forms/searchable_select.py
@register(category="forms")
def SearchableSelect(
    endpoint: str,
    name: str,
    placeholder: str = "Search...",
    min_chars: int = 2,
    debounce: int = 300,
    initial_options: list[tuple[str, str]] | None = None,
    required: bool = False,
    size: SizeType | None = None,
    select_id: str | None = None,
    csp_safe: bool | None = None,
    **kwargs: Any,
) -> Div:
    """Server-side searchable dropdown using HTMX.

    Replaces client-side libraries like Select2/Choices.js with pure
    server-side filtering. As user types, sends requests to server
    which returns filtered options.

    Args:
        endpoint: Server endpoint for search (receives 'q' query param)
        name: Form field name
        placeholder: Search input placeholder
        initial_options: Initial options as list of (value, text) tuples
        debounce: Milliseconds to wait after typing before searching
        min_chars: Minimum characters before triggering search
        select_id: Unique ID for the select element
        csp_safe: Avoid inline click handlers by using delegated listeners
            from FastStrap init script. `None` preserves the legacy default
            for backward compatibility and emits a deprecation warning.
        **kwargs: Additional HTML attributes

    Returns:
        Div containing search input and select dropdown

    Example:
        Basic usage:
        >>> SearchableSelect(
        ...     endpoint="/api/users/search",
        ...     name="user_id",
        ...     placeholder="Search users..."
        ... )

        With initial options:
        >>> SearchableSelect(
        ...     endpoint="/api/countries/search",
        ...     name="country",
        ...     initial_options=[
        ...         ("us", "United States"),
        ...         ("uk", "United Kingdom"),
        ...     ]
        ... )

        Server-side handler:
        ```python
        @app.get("/api/users/search")
        def search_users(q: str = ""):
            if len(q) < 2:
                return ""

            users = db.query(User).filter(
                User.name.ilike(f"%{q}%")
            ).limit(10).all()

            return Div(*[
                A(
                    user.name,
                    href="#",
                    cls="list-group-item list-group-item-action",
                    data_value=user.id,
                )
                for user in users
            ])
        ```

    Note:
        The server endpoint should:
        1. Receive 'q' query parameter with search term
        2. Filter results server-side
        3. Return HTML options to replace the results container

        For better UX, consider:
        - Showing loading indicator during search
        - Handling empty results gracefully
        - Limiting results to prevent overwhelming the UI
    """
    if initial_options is None:
        initial_options = []

    global _SEARCHABLE_SELECT_DEFAULT_WARNED
    if csp_safe is None:
        if not _SEARCHABLE_SELECT_DEFAULT_WARNED:
            warnings.warn(
                "SearchableSelect() defaults to inline click handlers today for backward "
                "compatibility. Pass csp_safe=True to avoid inline JavaScript. The default "
                "will change to csp_safe=True in a future release.",
                DeprecationWarning,
                stacklevel=2,
            )
            _SEARCHABLE_SELECT_DEFAULT_WARNED = True
        csp_safe = False

    # Resolve API defaults
    cfg = resolve_defaults("SearchableSelect", size=size)
    c_size = cfg.get("size", None)

    # Generate ID if not provided
    if select_id is None:
        select_id = _stable_searchable_select_id(
            endpoint=endpoint,
            name=name,
            placeholder=placeholder,
            min_chars=min_chars,
            debounce=debounce,
        )

    results_id = f"{select_id}-results"
    input_id = f"{select_id}-input"
    safe_select_id = json.dumps(select_id)
    safe_input_id = json.dumps(input_id)
    safe_results_id = json.dumps(results_id)

    # Build input classes
    input_classes = ["form-control"]
    if c_size:
        input_classes.append(f"form-control-{c_size}")
    input_classes.append("mb-2")

    # Build search input
    search_input = Input(
        type="search",
        id=input_id,
        placeholder=placeholder,
        cls=" ".join(input_classes),
        hx_get=endpoint,
        hx_trigger=f"keyup changed delay:{debounce}ms",
        hx_target=f"#{results_id}",
        hx_swap="innerHTML",
        autocomplete="off",
        minlength=min_chars if min_chars > 0 else None,
    )

    # Build initial options as list-group items
    option_elements = []
    for value, text in initial_options:
        option_attrs: dict[str, Any] = {
            "href": "#",
            "cls": "list-group-item list-group-item-action",
            "data_value": value,
        }
        if csp_safe:
            option_attrs.update(
                {
                    "data_fs_searchable_option": "true",
                    "data_fs_select_id": select_id,
                    "data_fs_input_id": input_id,
                    "data_fs_results_id": results_id,
                    "data_fs_label": text,
                    "data_fs_value": value,
                }
            )
        else:
            option_attrs["hx-on:click"] = (
                "event.preventDefault();"
                f"const sel=document.getElementById({safe_select_id});"
                "if(!sel){return;}"
                "sel.innerHTML='';"
                "const opt=document.createElement('option');"
                f"opt.value={json.dumps(value)};"
                f"opt.text={json.dumps(text)};"
                "opt.selected=true;"
                "sel.appendChild(opt);"
                f"const inp=document.getElementById({safe_input_id});"
                f"if(inp){{inp.value={json.dumps(text)};}}"
                f"const box=document.getElementById({safe_results_id});"
                "if(box){box.innerHTML='';}"
            )
        option_elements.append(A(text, **option_attrs))

    # Build results container
    results_container = Div(
        *option_elements,
        id=results_id,
        cls="list-group",
        style="max-height: 300px; overflow-y: auto;",
    )

    # Build hidden select for form submission
    hidden_options = [Option(text, value=value) for value, text in initial_options]
    hidden_select = Select(
        *hidden_options,
        name=name,
        id=select_id,
        cls="d-none",
        required=required,
        tabindex="-1",
        aria_hidden="true",
    )

    # Build container
    base_classes = ["searchable-select"]
    user_cls = kwargs.pop("cls", "")
    all_classes = merge_classes(" ".join(base_classes), user_cls)

    attrs: dict[str, Any] = {"cls": all_classes}
    if csp_safe:
        attrs["data_fs_searchable_select"] = "true"
    attrs.update(convert_attrs(kwargs))

    return Div(
        search_input,
        results_container,
        hidden_select,
        **attrs,
    )