Listbox
An accessible custom select with single/multi-select, full keyboard navigation, and typeahead.
Basic Listbox
Click the button or press Space / Enter / arrow keys to open. Use Up / Down to navigate,
Home / End to jump, Enter / Space to select, Escape to close, or simply type to jump
to an option. ARIA wiring (combobox, listbox,
aria-activedescendant) is handled for you.
<BitBhListbox TValue="string" Value="@favPerson"
OnValueChange="v => favPerson = v"
class="listbox">
<BitBhListboxButton aria-label="Person" TValue="string" class="listbox-button">
<Template Context="b">
<span>@(string.IsNullOrEmpty(b.Value) ? "Select a person…" : b.Value)</span>
<span class="listbox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhListboxButton>
<BitBhListboxOptions TValue="string" class="listbox-options">
@foreach (var name in people)
{
<BitBhListboxOption TValue="string" Value="@name" class="listbox-option">
@name
</BitBhListboxOption>
}
</BitBhListboxOptions>
</BitBhListbox>
@code {
private string? favPerson;
private string[] people = new[] {
"Alice", "Benjamin", "Caterina", "Daniel",
"Eleanor", "Felix", "Genevieve", "Henry"
};
}/* All styles are consumer-authored — the library ships zero CSS */
.listbox { position: relative; display: inline-block; min-width: 14rem; }
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
.listbox-button[data-disabled] { opacity: 0.5; cursor: not-allowed; }
.listbox-chevron { transition: transform 200ms ease; color: #71717a; }
.listbox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 31). */
.listbox-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: 14rem;
overflow-y: auto;
}
.listbox-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhListboxOption */
.listbox-option[data-active] { background: #eff6ff; }
.listbox-option[data-selected] { font-weight: 600; }
.listbox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
/* Selected-value readout shown beside the listbox */
.demo-output { font-size: 0.875rem; color: #71717a; }Multi-select
Set Multiple="true" to allow selecting many values. The panel stays
open after each selection. The button label here renders the joined names.
<BitBhListbox TValue="string" Multiple="true"
Values="@favColors"
OnValuesChange="v => favColors = v"
class="listbox">
<BitBhListboxButton aria-label="Colours" TValue="string" class="listbox-button">
<Template Context="b">
<span>
@(b.Values.Count == 0 ? "Pick colours…" : string.Join(", ", b.Values))
</span>
<span class="listbox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhListboxButton>
<BitBhListboxOptions TValue="string" class="listbox-options">
@foreach (var c in colors)
{
<BitBhListboxOption TValue="string" Value="@c" class="listbox-option">
<Template Context="o">
<span class="check-mark">@(o.IsSelected ? "✓" : "")</span>
<span>@c</span>
</Template>
</BitBhListboxOption>
}
</BitBhListboxOptions>
</BitBhListbox>
@code {
private IReadOnlyCollection<string> favColors = Array.Empty<string>();
private string[] colors = new[] { "Red", "Green", "Blue", "Yellow", "Purple", "Orange" };
}/* All styles are consumer-authored — the library ships zero CSS */
.listbox { position: relative; display: inline-block; min-width: 14rem; }
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
.listbox-button[data-disabled] { opacity: 0.5; cursor: not-allowed; }
.listbox-chevron { transition: transform 200ms ease; color: #71717a; }
.listbox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 31). */
.listbox-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: 14rem;
overflow-y: auto;
}
.listbox-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhListboxOption */
.listbox-option[data-active] { background: #eff6ff; }
.listbox-option[data-selected] { font-weight: 600; }
.listbox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.demo-output { font-size: 0.875rem; color: #71717a; }
/* The check-mark glyph is shown for selected options in multi-select mode. */
.check-mark {
display: inline-block;
width: 1rem;
color: #2563eb;
font-weight: 700;
}Disabled Options
Mark individual options with Disabled="true". They render with
data-disabled for CSS, are skipped by arrow-key nav, and ignore typeahead matches.
<BitBhListbox TValue="string" DefaultValue="standard" class="listbox">
<BitBhListboxButton aria-label="Plan" TValue="string" class="listbox-button">
<Template Context="b">
<span>@(b.Value ?? "Pick a plan")</span>
<span class="listbox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhListboxButton>
<BitBhListboxOptions TValue="string" class="listbox-options">
<BitBhListboxOption TValue="string" Value="standard" class="listbox-option">Standard</BitBhListboxOption>
<BitBhListboxOption TValue="string" Value="pro" class="listbox-option">Pro</BitBhListboxOption>
<BitBhListboxOption TValue="string" Value="enterprise" Disabled="true" class="listbox-option">
Enterprise (coming soon)
</BitBhListboxOption>
</BitBhListboxOptions>
</BitBhListbox>/* All styles are consumer-authored — the library ships zero CSS */
.listbox { position: relative; display: inline-block; min-width: 14rem; }
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
.listbox-button[data-disabled] { opacity: 0.5; cursor: not-allowed; }
.listbox-chevron { transition: transform 200ms ease; color: #71717a; }
.listbox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 31). */
.listbox-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: 14rem;
overflow-y: auto;
}
.listbox-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhListboxOption. Arrow-key navigation and typeahead skip
options marked with data-disabled. */
.listbox-option[data-active] { background: #eff6ff; }
.listbox-option[data-selected] { font-weight: 600; }
.listbox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }Custom Option Content
Options can render any markup. Use the data-active and
data-selected hooks to drive the highlighted and selected styles.
Provide a Label string when your content is rich so typeahead
still matches sensibly.
<BitBhListbox TValue="string" Value="@country"
OnValueChange="v => country = v"
class="listbox">
<BitBhListboxButton aria-label="Country" TValue="string" class="listbox-button">
<Template Context="b">
<span>@(LookupCountry(b.Value)?.Name ?? "Pick a country")</span>
<span class="listbox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhListboxButton>
<BitBhListboxOptions TValue="string" class="listbox-options">
@foreach (var c in countries)
{
<BitBhListboxOption TValue="string" Value="@c.Code"
Label="@c.Name"
class="listbox-option">
<span class="country-flag">@c.Flag</span>
<span class="country-name">@c.Name</span>
<span class="country-code">@c.Code</span>
</BitBhListboxOption>
}
</BitBhListboxOptions>
</BitBhListbox>
@code {
private record Country(string Code, string Name, string Flag);
private string? country = "fr";
private Country[] countries = new[]
{
new Country("us", "United States", "🇺🇸"),
new Country("gb", "United Kingdom", "🇬🇧"),
new Country("fr", "France", "🇫🇷"),
new Country("de", "Germany", "🇩🇪"),
new Country("jp", "Japan", "🇯🇵"),
new Country("br", "Brazil", "🇧🇷"),
};
private Country? LookupCountry(string? code) =>
code is null ? null : countries.FirstOrDefault(c => c.Code == code);
}/* All styles are consumer-authored — the library ships zero CSS */
.listbox { position: relative; display: inline-block; min-width: 14rem; }
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
.listbox-button[data-disabled] { opacity: 0.5; cursor: not-allowed; }
.listbox-chevron { transition: transform 200ms ease; color: #71717a; }
.listbox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 31). */
.listbox-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: 14rem;
overflow-y: auto;
}
.listbox-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhListboxOption */
.listbox-option[data-active] { background: #eff6ff; }
.listbox-option[data-selected] { font-weight: 600; }
.listbox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
/* Rich option layout: flag + name + code.
Provide Label="@c.Name" on each option so typeahead matches the visible
country name rather than the country code stored as the value. */
.country-flag { font-size: 1.05rem; }
.country-name { flex: 1 1 auto; }
.country-code {
font-size: 0.7rem;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.05em;
}Anchor Positioning
Set Anchor on BitBhListboxOptions to automatically position
the dropdown relative to the button 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. The CSS variable
--button-width is exposed so you can match the dropdown width to the trigger.
No position: relative on the parent is needed.
Bottom Start (width matched)
Top Start (flips down if no space)
@* Anchor positions the dropdown relative to the button automatically.
No position:relative on parent or position:absolute on panel needed.
The panel uses position:fixed and auto-updates on scroll/resize. *@
<BitBhListbox TValue="string" Value="@person"
OnValueChange="v => person = v"
class="listbox-anchored">
<BitBhListboxButton aria-label="Person" TValue="string" class="listbox-button">
<Template Context="b">
<span>@(b.Value ?? "Select a person…")</span>
<span class="listbox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhListboxButton>
<BitBhListboxOptions TValue="string"
Anchor="@(new BitBhAnchorOptions { To = "bottom start", Gap = 4 })"
class="listbox-options-anchored">
@foreach (var name in people)
{
<BitBhListboxOption TValue="string" Value="@name" class="listbox-option">
@name
</BitBhListboxOption>
}
</BitBhListboxOptions>
</BitBhListbox>
@* 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.
Use --button-width CSS variable to match the trigger width. *@/* All styles are consumer-authored — the library ships zero CSS */
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
.listbox-button[data-disabled] { opacity: 0.5; cursor: not-allowed; }
.listbox-chevron { transition: transform 200ms ease; color: #71717a; }
.listbox-chevron.open { transform: rotate(180deg); }
.listbox-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhListboxOption */
.listbox-option[data-active] { background: #eff6ff; }
.listbox-option[data-selected] { font-weight: 600; }
.listbox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.demo-output { font-size: 0.875rem; color: #71717a; }
/* When using Anchor, the panel is position:fixed by the JS engine.
No position:relative on parent needed. */
.listbox-anchored { display: inline-block; min-width: 14rem; }
.listbox-options-anchored {
z-index: 9999;
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: 14rem;
overflow-y: auto;
/* Match the button width using the exposed CSS variable */
width: var(--button-width);
}
/* Explicit hidden override — required when the panel has display set */
.listbox-options-anchored[hidden] { display: none; }Form Submission
Set Name on the listbox and the selection participates in plain HTML
form posts. In multi-select mode a separate hidden input is rendered for each value.
<form @onsubmit="HandleFormSubmit"
@onsubmit:preventDefault="true"
style="display:flex; flex-direction:column; gap:0.75rem; align-items:flex-start;">
<BitBhListbox TValue="string" Name="role" DefaultValue="user" class="listbox">
<BitBhListboxButton aria-label="Role" TValue="string" class="listbox-button">
<Template Context="b">
<span>@(b.Value)</span>
<span class="listbox-chevron @(b.IsOpen ? "open" : "")">▾</span>
</Template>
</BitBhListboxButton>
<BitBhListboxOptions TValue="string" class="listbox-options">
<BitBhListboxOption TValue="string" Value="user" class="listbox-option">User</BitBhListboxOption>
<BitBhListboxOption TValue="string" Value="admin" class="listbox-option">Admin</BitBhListboxOption>
<BitBhListboxOption TValue="string" Value="owner" class="listbox-option">Owner</BitBhListboxOption>
</BitBhListboxOptions>
</BitBhListbox>
<button type="submit" class="btn btn-primary">Submit</button>
@if (lastSubmitted is not null)
{
<span class="demo-output">@lastSubmitted</span>
}
</form>
@code {
private string? lastSubmitted;
private void HandleFormSubmit()
{
lastSubmitted = "(submitted — when single-select, posts role=<value>; when multi-select, posts a separate field per value)";
}
}
@* When Multiple="true" a separate hidden input is rendered per selected value. *@/* All styles are consumer-authored — the library ships zero CSS */
.listbox { position: relative; display: inline-block; min-width: 14rem; }
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
.listbox-button[data-disabled] { opacity: 0.5; cursor: not-allowed; }
.listbox-chevron { transition: transform 200ms ease; color: #71717a; }
.listbox-chevron.open { transform: rotate(180deg); }
/* Options panel — sits above the click-outside overlay (z-index: 31). */
.listbox-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: 14rem;
overflow-y: auto;
}
.listbox-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
cursor: pointer;
color: #18181b;
}
/* Hooks emitted by BitBhListboxOption */
.listbox-option[data-active] { background: #eff6ff; }
.listbox-option[data-selected] { font-weight: 600; }
.listbox-option[data-disabled] { opacity: 0.4; cursor: not-allowed; }
.demo-output { font-size: 0.875rem; color: #71717a; }
/* Submit button. When Name is set, BitBhListbox renders <input type="hidden">
sibling(s) — one for single-select, or one per value for multi-select. */
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
background: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn:hover { background: #f4f4f5; }
.btn-primary {
color: #fff;
background: #2563eb;
border-color: #2563eb;
}
.btn-primary:hover { background: #1d4ed8; border-color: #1d4ed8; }Large lists (virtualized)
For thousands of options, pass the data through Items and render each option
with an ItemTemplate. Virtualize="true" renders only the options in
view — give the panel a fixed height with overflow-y: auto and make every option
exactly ItemSize pixels tall. Options still get aria-setsize /
aria-posinset, and arrow keys, Home / End and typeahead reach all 10,000. With no
content, BitBhListboxButton shows the selected option's text or its
Placeholder (and emits data-placeholder).
<BitBhListbox TValue="string" @bind-Value="virtualPick" class="listbox-anchored">
@* No content: shows the selected text, or the placeholder *@
<BitBhListboxButton aria-label="Item" TValue="string" Placeholder="Choose one of 10,000…"
class="listbox-button listbox-button-auto" />
<BitBhListboxOptions TValue="string"
Items="@VirtualItems"
Virtualize="true"
ItemSize="36"
Anchor="@(new BitBhAnchorOptions { To = "bottom start", Gap = 4 })"
class="listbox-virtual">
<ItemTemplate Context="o">
<span class="listbox-virtual-check">@(o.IsSelected ? "✓" : "")</span>
<span>@o.Value</span>
</ItemTemplate>
</BitBhListboxOptions>
</BitBhListbox>
@code {
// 10 adjectives × 10 animals × 100 = 10,000 options.
private static readonly string[] VirtualItems = BuildVirtualItems();
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();
}
}/* All styles are consumer-authored — the library ships zero CSS */
.listbox-anchored { display: inline-block; min-width: 14rem; }
.listbox-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.listbox-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.listbox-button[data-state="open"] { border-color: #2563eb; }
/* Button without content: add the chevron with CSS, mute the placeholder */
.listbox-button-auto::after { content: "\25BE"; color: #71717a; }
.listbox-button-auto[data-placeholder] { color: #71717a; }
/* Virtualized panel: a fixed-height scroll container (positioned by Anchor) */
.listbox-virtual {
z-index: 9999;
width: var(--button-width);
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);
}
.listbox-virtual[hidden] { display: none; }
/* Data-driven options carry no class — target the role.
Every option must be exactly ItemSize (36px) tall. */
.listbox-virtual > [role="option"] {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
box-sizing: border-box;
padding: 0 0.65rem;
border-radius: 0.35rem;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.listbox-virtual > [role="option"][data-active] { background: #eff6ff; }
.listbox-virtual > [role="option"][data-selected] { font-weight: 600; }
.listbox-virtual-check { width: 1rem; color: #2563eb; font-weight: 700; }