Combobox
An accessible typeable autocomplete with consumer-driven filtering.
Basic Combobox
Type to filter, use Up / Down to navigate, Enter to select, Escape to close.
The combobox emits an OnQueryChange event; you do the filtering
in your own code and pass only the matching options as children.
<BitBhCombobox TValue="string"
Value="@selectedFruit"
OnValueChange="v => selectedFruit = v"
OnQueryChange="HandleFruitQuery"
DisplayValue="v => v ?? string.Empty"
class="combobox">
<div class="combobox-input-wrapper">
<BitBhComboboxInput TValue="string"
Placeholder="Pick a fruit…"
class="combobox-input" />
<BitBhComboboxButton TValue="string" class="combobox-button">
<Template Context="b">
<span class="combobox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhComboboxButton>
</div>
<BitBhComboboxOptions TValue="string" class="combobox-options">
@if (filteredFruits.Count == 0)
{
<li class="combobox-empty">No matches.</li>
}
else
{
@foreach (var fruit in filteredFruits)
{
<BitBhComboboxOption TValue="string" Value="@fruit" class="combobox-option">
@fruit
</BitBhComboboxOption>
}
}
</BitBhComboboxOptions>
</BitBhCombobox>
@code {
private string? selectedFruit;
private string[] allFruits = new[] {
"Apple", "Apricot", "Banana", "Blueberry", "Cherry",
"Coconut", "Cranberry", "Grape", "Kiwi", "Lemon",
"Lime", "Mango", "Orange", "Papaya", "Peach",
"Pear", "Pineapple", "Plum", "Raspberry", "Strawberry"
};
private List<string> filteredFruits = new();
private void HandleFruitQuery(string q) =>
filteredFruits = string.IsNullOrWhiteSpace(q)
? allFruits.ToList()
: allFruits.Where(f => f.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
protected override void OnInitialized() => filteredFruits = allFruits.ToList();
}/* All styles are consumer-authored — the library ships zero CSS */
.combobox { position: relative; display: inline-block; min-width: 18rem; }
.combobox-input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
transition: border-color 120ms ease;
}
.combobox-input-wrapper:focus-within { border-color: #2563eb; }
.combobox-input {
flex: 1 1 auto;
padding: 0.5rem 0.85rem;
border: 0;
background: transparent;
font-size: 0.9rem;
font-family: inherit;
color: #18181b;
outline: none;
}
.combobox-button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.65rem;
cursor: pointer;
color: #71717a;
}
.combobox-chevron { transition: transform 200ms ease; }
.combobox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 30 inline) */
.combobox-options {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 31;
margin: 0;
padding: 0.25rem;
list-style: none;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
max-height: 16rem;
overflow-y: auto;
}
.combobox-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhComboboxOption */
.combobox-option[data-active] { background: #eff6ff; }
.combobox-option[data-selected] { font-weight: 600; }
.combobox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.combobox-empty {
padding: 0.6rem 0.65rem;
font-size: 0.85rem;
color: #71717a;
font-style: italic;
}
.combobox-tick { color: #2563eb; font-weight: 700; }Multi-select with chips
Set Multiple="true" to allow many selections. Selected values are
shown as chips above the input (each with a remove button). The panel stays
open after each pick.
<BitBhCombobox TValue="string" Multiple="true"
Values="@pickedTags"
OnValuesChange="v => pickedTags = v"
OnQueryChange="HandleTagQuery"
class="combobox">
<div class="combobox-input-wrapper combobox-input-wrapper-multi">
@foreach (var tag in pickedTags)
{
<span class="combobox-chip">
@tag
<button type="button"
class="combobox-chip-remove"
@onclick="() => RemoveTag(tag)"
aria-label="@($"Remove {tag}")">×</button>
</span>
}
<BitBhComboboxInput TValue="string"
Placeholder="@(pickedTags.Count == 0 ? "Pick tags…" : "")"
class="combobox-input combobox-input-inline" />
<BitBhComboboxButton TValue="string" class="combobox-button">
<Template Context="b">
<span class="combobox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhComboboxButton>
</div>
<BitBhComboboxOptions TValue="string" class="combobox-options">
@if (filteredTags.Count == 0)
{
<li class="combobox-empty">No matches.</li>
}
else
{
@foreach (var tag in filteredTags)
{
<BitBhComboboxOption TValue="string" Value="@tag" class="combobox-option">
<Template Context="o">
<span class="combobox-tick">@(o.IsSelected ? "✓" : "")</span>
<span>@tag</span>
</Template>
</BitBhComboboxOption>
}
}
</BitBhComboboxOptions>
</BitBhCombobox>
@code {
private IReadOnlyCollection<string> pickedTags = Array.Empty<string>();
private string[] allTags = new[] {
"design", "engineering", "marketing", "sales", "support",
"research", "product", "operations", "finance", "legal"
};
private List<string> filteredTags = new();
private void HandleTagQuery(string q) =>
filteredTags = string.IsNullOrWhiteSpace(q)
? allTags.ToList()
: allTags.Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
private void RemoveTag(string tag) =>
pickedTags = pickedTags.Where(t => t != tag).ToList();
protected override void OnInitialized() => filteredTags = allTags.ToList();
}/* All styles are consumer-authored — the library ships zero CSS */
.combobox { position: relative; display: inline-block; min-width: 18rem; }
.combobox-input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
transition: border-color 120ms ease;
}
.combobox-input-wrapper:focus-within { border-color: #2563eb; }
.combobox-input {
flex: 1 1 auto;
padding: 0.5rem 0.85rem;
border: 0;
background: transparent;
font-size: 0.9rem;
font-family: inherit;
color: #18181b;
outline: none;
}
.combobox-button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.65rem;
cursor: pointer;
color: #71717a;
}
.combobox-chevron { transition: transform 200ms ease; }
.combobox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 30 inline) */
.combobox-options {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 31;
margin: 0;
padding: 0.25rem;
list-style: none;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
max-height: 16rem;
overflow-y: auto;
}
.combobox-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhComboboxOption */
.combobox-option[data-active] { background: #eff6ff; }
.combobox-option[data-selected] { font-weight: 600; }
.combobox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.combobox-empty {
padding: 0.6rem 0.65rem;
font-size: 0.85rem;
color: #71717a;
font-style: italic;
}
.combobox-tick { color: #2563eb; font-weight: 700; }
/* Multi-select chips */
.combobox-input-wrapper-multi {
flex-wrap: wrap;
padding: 0.25rem 0.35rem;
gap: 0.25rem;
}
.combobox-input-inline {
width: auto;
min-width: 6rem;
padding: 0.25rem 0.4rem;
}
.combobox-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
background: #eff6ff;
color: #1d4ed8;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
}
.combobox-chip-remove {
all: unset;
cursor: pointer;
font-size: 1rem;
line-height: 0.7;
padding: 0 0.1rem;
border-radius: 999px;
}
.combobox-chip-remove:hover { background: #dbeafe; }Object values with rich content
Bind to objects rather than strings. Provide a DisplayValue function
that converts the selected object back to the input text, and render rich content
inside each option.
public sealed record Person(string Name, string Email)
{
public string Initials =>
string.Concat(Name.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Take(2)
.Select(p => p[0]));
}
<BitBhCombobox TValue="Person"
Value="@selectedPerson"
OnValueChange="v => selectedPerson = v"
OnQueryChange="HandlePersonQuery"
DisplayValue="p => p?.Name ?? string.Empty"
class="combobox">
<div class="combobox-input-wrapper">
<BitBhComboboxInput TValue="Person"
Placeholder="Search people…"
class="combobox-input" />
<BitBhComboboxButton TValue="Person" class="combobox-button">
<Template Context="b">
<span class="combobox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhComboboxButton>
</div>
<BitBhComboboxOptions TValue="Person" class="combobox-options">
@if (filteredPeople.Count == 0)
{
<li class="combobox-empty">No matches.</li>
}
else
{
@foreach (var person in filteredPeople)
{
<BitBhComboboxOption TValue="Person" Value="@person" class="combobox-option">
<Template Context="o">
<div class="person-row">
<span class="person-avatar">@person.Initials</span>
<div class="person-info">
<strong>@person.Name</strong>
<span>@person.Email</span>
</div>
@if (o.IsSelected) { <span class="combobox-tick">✓</span> }
</div>
</Template>
</BitBhComboboxOption>
}
}
</BitBhComboboxOptions>
</BitBhCombobox>
@code {
private Person? selectedPerson;
private Person[] allPeople = new[]
{
new Person("Alice Anderson", "[email protected]"),
new Person("Benjamin Brown", "[email protected]"),
new Person("Caterina Costa", "[email protected]"),
new Person("Daniel Davis", "[email protected]"),
new Person("Eleanor Evans", "[email protected]"),
new Person("Felix Foster", "[email protected]"),
new Person("Genevieve Gomez", "[email protected]"),
new Person("Henry Hughes", "[email protected]"),
};
private List<Person> filteredPeople = new();
private void HandlePersonQuery(string q) =>
filteredPeople = string.IsNullOrWhiteSpace(q)
? allPeople.ToList()
: allPeople.Where(p =>
p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
p.Email.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
protected override void OnInitialized() => filteredPeople = allPeople.ToList();
}/* All styles are consumer-authored — the library ships zero CSS */
.combobox { position: relative; display: inline-block; min-width: 18rem; }
.combobox-input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
transition: border-color 120ms ease;
}
.combobox-input-wrapper:focus-within { border-color: #2563eb; }
.combobox-input {
flex: 1 1 auto;
padding: 0.5rem 0.85rem;
border: 0;
background: transparent;
font-size: 0.9rem;
font-family: inherit;
color: #18181b;
outline: none;
}
.combobox-button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.65rem;
cursor: pointer;
color: #71717a;
}
.combobox-chevron { transition: transform 200ms ease; }
.combobox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 30 inline) */
.combobox-options {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 31;
margin: 0;
padding: 0.25rem;
list-style: none;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
max-height: 16rem;
overflow-y: auto;
}
.combobox-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhComboboxOption */
.combobox-option[data-active] { background: #eff6ff; }
.combobox-option[data-selected] { font-weight: 600; }
.combobox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.combobox-empty {
padding: 0.6rem 0.65rem;
font-size: 0.85rem;
color: #71717a;
font-style: italic;
}
.combobox-tick { color: #2563eb; font-weight: 700; }
/* Rich-content option for an object-typed combobox */
.person-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
}
.person-avatar {
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #e0e7ff;
color: #1d4ed8;
font-size: 0.7rem;
font-weight: 700;
}
.person-info {
flex: 1 1 auto;
display: flex;
flex-direction: column;
line-height: 1.25;
}
.person-info strong { font-size: 0.85rem; color: #18181b; }
.person-info span { font-size: 0.75rem; color: #71717a; }Anchor Positioning
Set Anchor on BitBhComboboxOptions to automatically position
the dropdown relative to the input using position: fixed. The panel
flips to the opposite side when there's not enough space, shifts to stay within
the viewport, and auto-updates on scroll/resize. No position: relative
on the parent is needed.
Bottom Start
Top Start (flips down if no space)
@* Anchor positions the dropdown relative to the input automatically.
No position:relative on parent or position:absolute on panel needed.
The panel uses position:fixed and auto-updates on scroll/resize. *@
<BitBhCombobox TValue="string"
Value="@selectedFruit"
OnValueChange="v => selectedFruit = v"
OnQueryChange="HandleFruitQuery"
DisplayValue="v => v ?? string.Empty"
class="combobox-anchored">
<div class="combobox-input-wrapper">
<BitBhComboboxInput TValue="string" Placeholder="Pick a fruit…" class="combobox-input" />
<BitBhComboboxButton TValue="string" class="combobox-button">▾</BitBhComboboxButton>
</div>
<BitBhComboboxOptions TValue="string"
Anchor="@(new BitBhAnchorOptions { To = "bottom start", Gap = 4 })"
class="combobox-options-anchored">
@foreach (var fruit in filteredFruits)
{
<BitBhComboboxOption TValue="string" Value="@fruit" class="combobox-option">
@fruit
</BitBhComboboxOption>
}
</BitBhComboboxOptions>
</BitBhCombobox>
@* Available placements: top, top start, top end,
bottom, bottom start, bottom end,
left, left start, left end,
right, right start, right end
The panel auto-flips to the opposite side if there's not enough space. *@/* All styles are consumer-authored — the library ships zero CSS */
.combobox-input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
transition: border-color 120ms ease;
}
.combobox-input-wrapper:focus-within { border-color: #2563eb; }
.combobox-input {
flex: 1 1 auto;
padding: 0.5rem 0.85rem;
border: 0;
background: transparent;
font-size: 0.9rem;
font-family: inherit;
color: #18181b;
outline: none;
}
.combobox-button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.65rem;
cursor: pointer;
color: #71717a;
}
.combobox-chevron { transition: transform 200ms ease; }
.combobox-chevron.open { transform: rotate(180deg); }
.combobox-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhComboboxOption */
.combobox-option[data-active] { background: #eff6ff; }
.combobox-option[data-selected] { font-weight: 600; }
.combobox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.combobox-empty {
padding: 0.6rem 0.65rem;
font-size: 0.85rem;
color: #71717a;
font-style: italic;
}
.combobox-tick { color: #2563eb; font-weight: 700; }
/* When using Anchor, the panel is position:fixed by the JS engine.
No position:relative on parent needed. */
.combobox-anchored { display: inline-block; min-width: 18rem; }
.combobox-options-anchored {
z-index: 9999;
min-width: 18rem;
margin: 0;
padding: 0.25rem;
list-style: none;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
max-height: 16rem;
overflow-y: auto;
}
/* Explicit hidden override — required when the panel has display set */
.combobox-options-anchored[hidden] { display: none; }Large lists (virtualized)
Pass the matching data to BitBhComboboxOptions through Items with an
ItemTemplate, and set Virtualize="true" so only the options in view
are rendered (fixed ItemSize, scrollable panel). Filtering stays yours:
OnQueryChange runs BitBhFilter.Contains — case- and
accent-insensitive — over 10,000 items on every keystroke. Child content such as
BitBhComboboxEmpty still renders after the data options.
<BitBhCombobox TValue="string"
@bind-Value="virtualPick"
OnQueryChange="HandleVirtualQuery"
DisplayValue="v => v ?? string.Empty"
class="combobox-anchored">
<div class="combobox-input-wrapper">
<BitBhComboboxInput TValue="string" Placeholder="Search 10,000 animals…" class="combobox-input" />
<BitBhComboboxButton TValue="string" class="combobox-button">
<Template Context="b">
<span class="combobox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhComboboxButton>
</div>
<BitBhComboboxOptions TValue="string"
Items="@virtualMatches"
Virtualize="true"
ItemSize="36"
Anchor="@(new BitBhAnchorOptions { To = "bottom start", Gap = 4 })"
class="combobox-virtual">
<ItemTemplate Context="o">
<span>@o.Value</span>
@if (o.IsSelected) { <span class="combobox-tick">✓</span> }
</ItemTemplate>
<ChildContent>
@* Rendered after the data options when nothing matches *@
<BitBhComboboxEmpty TValue="string" class="combobox-empty">No animals match.</BitBhComboboxEmpty>
</ChildContent>
</BitBhComboboxOptions>
</BitBhCombobox>
@code {
// 10 adjectives × 10 animals × 100 = 10,000 items.
private static readonly string[] VirtualItems = BuildVirtualItems();
private IReadOnlyList<string> virtualMatches = VirtualItems;
private string? virtualPick;
private static string[] BuildVirtualItems()
{
string[] adjectives = ["Amber", "Brave", "Calm", "Dusty", "Eager", "Fuzzy", "Gentle", "Hazy", "Icy", "Jolly"];
string[] animals = ["Badger", "Crane", "Dingo", "Falcon", "Gecko", "Heron", "Ibis", "Jaguar", "Koala", "Lynx"];
return Enumerable.Range(0, 10_000)
.Select(i => $"{adjectives[i / 1000]} {animals[i / 100 % 10]} {i % 100 + 1}")
.ToArray();
}
// Case- and accent-insensitive "contains" match.
private void HandleVirtualQuery(string query) =>
virtualMatches = VirtualItems.Where(item => BitBhFilter.Contains(item, query)).ToArray();
}/* All styles are consumer-authored — the library ships zero CSS */
.combobox-anchored { display: inline-block; min-width: 18rem; }
.combobox-input-wrapper {
display: flex;
align-items: center;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
}
.combobox-input-wrapper:focus-within { border-color: #2563eb; }
.combobox-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.5rem 0.85rem;
border: 0;
background: transparent;
font: inherit;
font-size: 0.9rem;
outline: none;
}
.combobox-button { all: unset; display: inline-flex; padding: 0 0.65rem; cursor: pointer; color: #71717a; }
.combobox-chevron { transition: transform 200ms ease; }
.combobox-chevron.open { transform: rotate(180deg); }
/* Virtualized panel: a scroll container with a bounded height (positioned by Anchor) */
.combobox-virtual {
z-index: 9999;
width: 18rem;
max-height: 16rem;
overflow-y: auto;
margin: 0;
padding: 0.25rem;
list-style: none;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
.combobox-virtual[hidden] { display: none; }
/* Data-driven options carry no class — target the role.
Every option must be exactly ItemSize (36px) tall. */
.combobox-virtual > [role="option"] {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
box-sizing: border-box;
padding: 0 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.combobox-virtual > [role="option"][data-active] { background: #eff6ff; }
.combobox-virtual > [role="option"][data-selected] { font-weight: 600; }
.combobox-empty { padding: 0.6rem 0.65rem; color: #71717a; font-size: 0.85rem; font-style: italic; }
.combobox-tick { color: #2563eb; font-weight: 700; }