Command Palette
A cmdk-style command menu — fuzzy search, groups, nested pages and a ⌘K palette — with zero visual opinion.
Inline command menu
A search field over a filterable list of actions. The input is a combobox that drives a
listbox through aria-activedescendant, so focus never leaves it. Type to
fuzzy-filter — "smiley" finds Search emoji through its Keywords — then use ↑ / ↓
(or Ctrl+N / Ctrl+P), Alt+↑ / Alt+↓ to jump between groups, and Enter to run. Empty groups hide,
separators disappear while searching, the disabled item is skipped, and the result count is announced.
<BitBhCommand Label="Command menu" class="cmd">
<div class="cmd-input-row">
<span class="cmd-input-icon" aria-hidden="true">⌕</span>
<BitBhCommandInput AutoFocus="false" class="cmd-input" placeholder="Type a command or search…" />
</div>
<BitBhCommandList class="cmd-list">
<BitBhCommandEmpty class="cmd-empty">No results found.</BitBhCommandEmpty>
<BitBhCommandGroup Heading="Suggestions" class="cmd-group">
<BitBhCommandItem Value="Calendar" OnSelect='@(() => Run("Calendar"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">📅</span> Calendar
</BitBhCommandItem>
<BitBhCommandItem Value="Search emoji" Keywords='@(["emoji", "smiley", "reaction"])'
OnSelect='@(() => Run("Search emoji"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">😀</span> Search emoji
</BitBhCommandItem>
<BitBhCommandItem Value="Calculator" Disabled="true" class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🧮</span> Calculator
</BitBhCommandItem>
</BitBhCommandGroup>
<BitBhCommandSeparator class="cmd-separator" />
<BitBhCommandGroup Heading="Settings" class="cmd-group">
<BitBhCommandItem Value="Profile" OnSelect='@(() => Run("Profile"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">👤</span> Profile
<span class="cmd-item-shortcut">⌘P</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Billing" Keywords='@(["invoice", "payment", "plan"])'
OnSelect='@(() => Run("Billing"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">💳</span> Billing
<span class="cmd-item-shortcut">⌘B</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Settings" Keywords='@(["preferences", "options"])'
OnSelect='@(() => Run("Settings"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">⚙️</span> Settings
<span class="cmd-item-shortcut">⌘S</span>
</BitBhCommandItem>
</BitBhCommandGroup>
</BitBhCommandList>
</BitBhCommand>
<span class="demo-output">Last command: <strong>@(lastCommand ?? "(none)")</strong></span>
@code {
private string? lastCommand;
private void Run(string command) => lastCommand = command;
}/* All styles are consumer-authored — the library ships zero CSS */
.cmd {
display: flex;
flex-direction: column;
width: 100%;
max-width: 28rem;
overflow: hidden;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
/* Focus stays in the input while navigating, so ring the whole card */
.cmd:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.cmd-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border-bottom: 1px solid #e4e4e7;
}
.cmd-input-icon { color: #a1a1aa; }
/* BitBhCommandInput renders <input role="combobox"> */
.cmd-input {
flex: 1;
min-width: 0;
height: 2.75rem;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.9rem;
color: #18181b;
}
.cmd-input::placeholder { color: #a1a1aa; }
/* BitBhCommandList renders role="listbox" */
.cmd-list {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.375rem;
scroll-padding-block: 0.375rem;
}
/* BitBhCommandEmpty only renders while nothing matches */
.cmd-empty {
padding: 2rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #71717a;
}
/* Groups get the hidden attribute when none of their items match */
.cmd-group[hidden] { display: none; }
.cmd-group [data-bit-bh-command-group-heading] {
padding: 0.5rem 0.6rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #71717a;
}
.cmd-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 0.4rem;
font-size: 0.875rem;
color: #27272a;
cursor: pointer;
user-select: none;
}
/* data-selected marks the highlighted item (the aria-activedescendant target) */
.cmd-item[data-selected] { background: #eff6ff; color: #1d4ed8; }
.cmd-item[data-disabled] { opacity: 0.45; cursor: not-allowed; }
.cmd-item-icon { width: 1.25rem; flex-shrink: 0; text-align: center; }
.cmd-item-shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #71717a;
}
.cmd-item[data-selected] .cmd-item-shortcut { color: #60a5fa; }
/* BitBhCommandSeparator is removed while searching */
.cmd-separator { height: 1px; margin: 0.375rem -0.375rem; background: #e4e4e7; }Nested pages
Turn one menu into a drill-down. Wrap each level in a BitBhCommandPage (no
Name = the root) and navigate with PushPageAsync on the context its
Template receives — the search clears on every push. Backspace in an empty input goes
back one level, and PageChanged keeps the breadcrumb in sync. Try Settings → Change theme.
<BitBhCommand Label="Workspace commands" PageChanged="OnPageChanged" class="cmd">
<div class="cmd-breadcrumbs">
<span class="cmd-crumb">Home</span>
@foreach (var crumb in crumbs)
{
<span class="cmd-crumb-sep" aria-hidden="true">›</span>
<span class="cmd-crumb">@crumb</span>
}
@if (crumbs.Count > 0)
{
<span class="cmd-crumb-hint">Backspace to go back</span>
}
</div>
<div class="cmd-input-row">
<BitBhCommandInput AutoFocus="false" class="cmd-input" placeholder="@PagePlaceholder" />
</div>
<BitBhCommandList class="cmd-list">
<BitBhCommandEmpty class="cmd-empty">No results found.</BitBhCommandEmpty>
<BitBhCommandPage>
<Template Context="cmd">
<BitBhCommandGroup Heading="Workspace" class="cmd-group">
<BitBhCommandItem Value="Search projects" OnSelect='@(() => cmd.PushPageAsync("Projects"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">📁</span> Search projects…
<span class="cmd-item-arrow" aria-hidden="true">›</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Settings" OnSelect='@(() => cmd.PushPageAsync("Settings"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">⚙️</span> Settings…
<span class="cmd-item-arrow" aria-hidden="true">›</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Invite teammates" OnSelect='@(() => CompleteAsync(cmd, "Invite teammates"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">✉️</span> Invite teammates
</BitBhCommandItem>
</BitBhCommandGroup>
</Template>
</BitBhCommandPage>
<BitBhCommandPage Name="Projects">
<Template Context="cmd">
@foreach (var project in projects)
{
<BitBhCommandItem Value="@project" OnSelect='@(() => CompleteAsync(cmd, $"Open {project}"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">📁</span> @project
</BitBhCommandItem>
}
</Template>
</BitBhCommandPage>
<BitBhCommandPage Name="Settings">
<Template Context="cmd">
<BitBhCommandItem Value="Change theme" OnSelect='@(() => cmd.PushPageAsync("Theme"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🎨</span> Change theme…
<span class="cmd-item-arrow" aria-hidden="true">›</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Notifications" OnSelect='@(() => CompleteAsync(cmd, "Notification settings"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🔔</span> Notifications
</BitBhCommandItem>
</Template>
</BitBhCommandPage>
<BitBhCommandPage Name="Theme">
<Template Context="cmd">
@foreach (var option in themes)
{
<BitBhCommandItem Value="@option" OnSelect='@(() => SetThemeAsync(cmd, option))' class="cmd-item">
@option
@if (option == theme)
{
<span class="cmd-item-check" aria-hidden="true">✓</span>
}
</BitBhCommandItem>
}
</Template>
</BitBhCommandPage>
</BitBhCommandList>
</BitBhCommand>
<span class="demo-output">Last command: <strong>@(lastCommand ?? "(none)")</strong> · Theme: <strong>@theme</strong></span>
@code {
private readonly List<string> crumbs = new();
private readonly string[] projects = ["bit Bheadless", "Docs site", "Demo app", "Design tokens"];
private readonly string[] themes = ["Light", "Dark", "System"];
private string theme = "System";
private string? lastCommand;
private string PagePlaceholder => crumbs.Count == 0
? "What do you need?"
: $"Search {crumbs[^1].ToLowerInvariant()}…";
// PageChanged reports the new current page: a pushed page, the page
// Backspace went back to, or null for the root.
private void OnPageChanged(string? page)
{
var index = page is null ? -1 : crumbs.IndexOf(page);
if (page is not null && index < 0) crumbs.Add(page);
else crumbs.RemoveRange(index + 1, crumbs.Count - index - 1);
}
// Run the command, then return to the root page.
private async Task CompleteAsync(BitBhCommandContext cmd, string command)
{
lastCommand = command;
await cmd.ClearSearchAsync();
while (cmd.Page is not null)
await cmd.PopPageAsync();
}
private Task SetThemeAsync(BitBhCommandContext cmd, string option)
{
theme = option;
return CompleteAsync(cmd, $"Theme: {option}");
}
}/* All styles are consumer-authored — the library ships zero CSS */
.cmd {
display: flex;
flex-direction: column;
width: 100%;
max-width: 28rem;
overflow: hidden;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
/* Focus stays in the input while navigating, so ring the whole card */
.cmd:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.cmd-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border-bottom: 1px solid #e4e4e7;
}
.cmd-input-icon { color: #a1a1aa; }
/* BitBhCommandInput renders <input role="combobox"> */
.cmd-input {
flex: 1;
min-width: 0;
height: 2.75rem;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.9rem;
color: #18181b;
}
.cmd-input::placeholder { color: #a1a1aa; }
/* BitBhCommandList renders role="listbox" */
.cmd-list {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.375rem;
scroll-padding-block: 0.375rem;
}
/* BitBhCommandEmpty only renders while nothing matches */
.cmd-empty {
padding: 2rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #71717a;
}
/* Groups get the hidden attribute when none of their items match */
.cmd-group[hidden] { display: none; }
.cmd-group [data-bit-bh-command-group-heading] {
padding: 0.5rem 0.6rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #71717a;
}
.cmd-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 0.4rem;
font-size: 0.875rem;
color: #27272a;
cursor: pointer;
user-select: none;
}
/* data-selected marks the highlighted item (the aria-activedescendant target) */
.cmd-item[data-selected] { background: #eff6ff; color: #1d4ed8; }
.cmd-item[data-disabled] { opacity: 0.45; cursor: not-allowed; }
.cmd-item-icon { width: 1.25rem; flex-shrink: 0; text-align: center; }
.cmd-item-shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #71717a;
}
.cmd-item[data-selected] .cmd-item-shortcut { color: #60a5fa; }
/* BitBhCommandSeparator is removed while searching */
.cmd-separator { height: 1px; margin: 0.375rem -0.375rem; background: #e4e4e7; }
/* Breadcrumb of the page stack, kept in sync through PageChanged */
.cmd-breadcrumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.6rem 0.85rem 0;
font-size: 0.75rem;
color: #71717a;
}
.cmd-crumb {
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: #f4f4f5;
color: #3f3f46;
}
.cmd-crumb-sep { color: #d4d4d8; }
.cmd-crumb-hint { margin-left: auto; color: #a1a1aa; }
.cmd-item-arrow { margin-left: auto; color: #a1a1aa; }
.cmd-item-check { margin-left: auto; font-weight: 600; color: #2563eb; }⌘K command palette
BitBhCommandDialog puts a command menu in a modal dialog and registers a global
Shortcut ("mod+k" = ⌘K on macOS, Ctrl+K elsewhere — press it anywhere on this page).
Running a command closes the palette, Escape or an outside click dismisses it, and focus returns where it was.
Drive it with Open; OpenChanged reports the changes the palette makes itself
(shortcut, Escape, outside click, running a command).
<button type="button" class="cmd-trigger" @onclick="() => paletteOpen = true">
<span class="cmd-input-icon" aria-hidden="true">⌕</span>
Search or jump to…
<span class="cmd-trigger-keys" aria-hidden="true">
<kbd class="cmd-kbd">Ctrl</kbd><kbd class="cmd-kbd">K</kbd>
</span>
</button>
<BitBhCommandDialog Open="paletteOpen" OpenChanged="OnPaletteOpenChanged" Shortcut="mod+k"
Label="Command palette" Loop="true"
OverlayClass="cmd-overlay" ContentClass="cmd-dialog" CommandClass="cmd-palette">
<div class="cmd-input-row">
<span class="cmd-input-icon" aria-hidden="true">⌕</span>
<BitBhCommandInput class="cmd-input" placeholder="Type a command or search…" />
<kbd class="cmd-kbd" aria-hidden="true">Esc</kbd>
</div>
<BitBhCommandList class="cmd-list">
<BitBhCommandEmpty class="cmd-empty">No results found.</BitBhCommandEmpty>
<BitBhCommandGroup Heading="Actions" class="cmd-group">
<BitBhCommandItem Value="New document" OnSelect='@(() => Run("New document"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">📝</span> New document
<span class="cmd-item-shortcut">⌘N</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Share" Keywords='@(["invite", "link", "collaborate"])'
OnSelect='@(() => Run("Share"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🔗</span> Share…
</BitBhCommandItem>
<BitBhCommandItem Value="Duplicate" OnSelect='@(() => Run("Duplicate"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">📄</span> Duplicate
<span class="cmd-item-shortcut">⌘D</span>
</BitBhCommandItem>
</BitBhCommandGroup>
<BitBhCommandSeparator class="cmd-separator" />
<BitBhCommandGroup Heading="Appearance" class="cmd-group">
<BitBhCommandItem Value="Toggle dark mode" Keywords='@(["theme", "night", "light"])'
OnSelect='@(() => Run("Toggle dark mode"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🌙</span> Toggle dark mode
</BitBhCommandItem>
<BitBhCommandItem Value="Zoom in" Keywords='@(["bigger", "font size"])'
OnSelect='@(() => Run("Zoom in"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🔍</span> Zoom in
<span class="cmd-item-shortcut">⌘+</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Zoom out" Keywords='@(["smaller", "font size"])'
OnSelect='@(() => Run("Zoom out"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">🔎</span> Zoom out
<span class="cmd-item-shortcut">⌘−</span>
</BitBhCommandItem>
</BitBhCommandGroup>
<BitBhCommandSeparator class="cmd-separator" />
<BitBhCommandGroup Heading="Help" class="cmd-group">
<BitBhCommandItem Value="Keyboard shortcuts" OnSelect='@(() => Run("Keyboard shortcuts"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">⌨️</span> Keyboard shortcuts
<span class="cmd-item-shortcut">?</span>
</BitBhCommandItem>
<BitBhCommandItem Value="Contact support" Keywords='@(["help", "email", "feedback"])'
OnSelect='@(() => Run("Contact support"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">💬</span> Contact support
</BitBhCommandItem>
</BitBhCommandGroup>
</BitBhCommandList>
<div class="cmd-footer" aria-hidden="true">
<span><kbd class="cmd-kbd">↑</kbd><kbd class="cmd-kbd">↓</kbd> navigate</span>
<span><kbd class="cmd-kbd">↵</kbd> run</span>
<span><kbd class="cmd-kbd">Esc</kbd> close</span>
</div>
</BitBhCommandDialog>
<span class="demo-output">Last command: <strong>@(lastCommand ?? "(none)")</strong> · Opened by the palette itself: <strong>@selfOpens</strong>×</span>
@code {
private bool paletteOpen;
private int selfOpens;
private string? lastCommand;
private void Run(string command) => lastCommand = command;
// Fires when the palette changes its own state: the ⌘K / Ctrl+K shortcut,
// Escape, an outside click, or running a command (which closes it).
private void OnPaletteOpenChanged(bool open)
{
paletteOpen = open;
if (open) selfOpens++;
}
}/* All styles are consumer-authored — the library ships zero CSS */
.cmd {
display: flex;
flex-direction: column;
width: 100%;
max-width: 28rem;
overflow: hidden;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
/* Focus stays in the input while navigating, so ring the whole card */
.cmd:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.cmd-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border-bottom: 1px solid #e4e4e7;
}
.cmd-input-icon { color: #a1a1aa; }
/* BitBhCommandInput renders <input role="combobox"> */
.cmd-input {
flex: 1;
min-width: 0;
height: 2.75rem;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.9rem;
color: #18181b;
}
.cmd-input::placeholder { color: #a1a1aa; }
/* BitBhCommandList renders role="listbox" */
.cmd-list {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.375rem;
scroll-padding-block: 0.375rem;
}
/* BitBhCommandEmpty only renders while nothing matches */
.cmd-empty {
padding: 2rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #71717a;
}
/* Groups get the hidden attribute when none of their items match */
.cmd-group[hidden] { display: none; }
.cmd-group [data-bit-bh-command-group-heading] {
padding: 0.5rem 0.6rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #71717a;
}
.cmd-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 0.4rem;
font-size: 0.875rem;
color: #27272a;
cursor: pointer;
user-select: none;
}
/* data-selected marks the highlighted item (the aria-activedescendant target) */
.cmd-item[data-selected] { background: #eff6ff; color: #1d4ed8; }
.cmd-item[data-disabled] { opacity: 0.45; cursor: not-allowed; }
.cmd-item-icon { width: 1.25rem; flex-shrink: 0; text-align: center; }
.cmd-item-shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #71717a;
}
.cmd-item[data-selected] .cmd-item-shortcut { color: #60a5fa; }
/* BitBhCommandSeparator is removed while searching */
.cmd-separator { height: 1px; margin: 0.375rem -0.375rem; background: #e4e4e7; }
.cmd-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.3rem;
border: 1px solid #e4e4e7;
border-bottom-width: 2px;
border-radius: 0.3rem;
background: #fafafa;
font-family: inherit;
font-size: 0.68rem;
color: #52525b;
}
.cmd-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
max-width: 20rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background: #fff;
font: inherit;
font-size: 0.875rem;
text-align: left;
color: #71717a;
cursor: pointer;
transition: border-color 120ms ease;
}
.cmd-trigger:hover { border-color: #a1a1aa; }
.cmd-trigger:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.cmd-trigger-keys { display: inline-flex; gap: 0.2rem; margin-left: auto; }
/* OverlayClass → BitBhDialogBackdrop */
.cmd-overlay {
position: fixed;
inset: 0;
background: rgba(24, 24, 27, 0.45);
backdrop-filter: blur(2px);
}
/* ContentClass → BitBhDialogPanel (the dialog root already covers the viewport) */
.cmd-dialog {
position: relative;
z-index: 1;
width: calc(100% - 2rem);
max-width: 36rem;
margin: 12vh auto 0;
overflow: hidden;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.85rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
outline: none;
}
.cmd-dialog[data-state="open"] { animation: cmd-dialog-in 140ms ease-out; }
@keyframes cmd-dialog-in {
from { opacity: 0; transform: translateY(-6px) scale(0.98); }
}
/* CommandClass → the BitBhCommand inside the panel (the panel is the card) */
.cmd-palette { display: flex; flex-direction: column; }
.cmd-palette .cmd-list { max-height: min(22rem, 50vh); }
.cmd-footer {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.85rem;
border-top: 1px solid #e4e4e7;
background: #fafafa;
font-size: 0.75rem;
color: #71717a;
}
.cmd-footer span { display: inline-flex; align-items: center; gap: 0.25rem; }Server-side search
Set ShouldFilter="false" when results come from somewhere else (an API, a database):
the menu then renders exactly the items you give it. Handle SearchChanged, fetch, and
re-render the list — highlight, keyboard navigation, the empty state and the results announcement keep
working. This demo fakes a 350 ms round-trip and drops stale responses.
<BitBhCommand Label="Search the docs" ShouldFilter="false" SearchChanged="SearchDocsAsync" class="cmd">
<div class="cmd-input-row">
<span class="cmd-input-icon" aria-hidden="true">⌕</span>
<BitBhCommandInput AutoFocus="false" class="cmd-input" placeholder="Search components…" />
@if (searching)
{
<span class="cmd-spinner" aria-hidden="true"></span>
}
</div>
<BitBhCommandList class="cmd-list">
@if (!searching)
{
<BitBhCommandEmpty class="cmd-empty">No components match “@docsQuery”.</BitBhCommandEmpty>
}
@foreach (var doc in docResults)
{
<BitBhCommandItem @key="doc.Name" Value="@doc.Name" OnSelect='@(() => Run($"Open {doc.Name} docs"))' class="cmd-item">
<span class="cmd-item-icon" aria-hidden="true">📄</span>
<span class="cmd-item-body">
<span class="cmd-item-title">@doc.Name</span>
<span class="cmd-item-description">@doc.Summary</span>
</span>
</BitBhCommandItem>
}
</BitBhCommandList>
</BitBhCommand>
<span class="demo-output">Last command: <strong>@(lastCommand ?? "(none)")</strong></span>
@code {
private sealed record Doc(string Name, string Summary);
private static readonly Doc[] _docs =
[
new("Accordion", "Vertically stacked, collapsible sections"),
new("Checkbox", "Two- or three-state toggle with a label"),
new("Combobox", "Text input with a filterable list of options"),
new("Command Palette", "Searchable, keyboard-first list of actions"),
new("Dialog", "Modal window with a focus trap"),
// …
];
private IReadOnlyList<Doc> docResults = _docs.Take(6).ToList();
private string docsQuery = "";
private bool searching;
private int searchVersion;
private string? lastCommand;
private void Run(string command) => lastCommand = command;
private async Task SearchDocsAsync(string query)
{
docsQuery = query;
var version = ++searchVersion;
searching = true;
await Task.Delay(350); // pretend this is an HTTP call
if (version != searchVersion) return; // a newer keystroke superseded this one
docResults = string.IsNullOrWhiteSpace(query)
? _docs.Take(6).ToList()
: BitBhFilter.Fuzzy(_docs, query, d => d.Name).ToList();
searching = false;
}
}/* All styles are consumer-authored — the library ships zero CSS */
.cmd {
display: flex;
flex-direction: column;
width: 100%;
max-width: 28rem;
overflow: hidden;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
/* Focus stays in the input while navigating, so ring the whole card */
.cmd:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.cmd-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border-bottom: 1px solid #e4e4e7;
}
.cmd-input-icon { color: #a1a1aa; }
/* BitBhCommandInput renders <input role="combobox"> */
.cmd-input {
flex: 1;
min-width: 0;
height: 2.75rem;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.9rem;
color: #18181b;
}
.cmd-input::placeholder { color: #a1a1aa; }
/* BitBhCommandList renders role="listbox" */
.cmd-list {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.375rem;
scroll-padding-block: 0.375rem;
}
/* BitBhCommandEmpty only renders while nothing matches */
.cmd-empty {
padding: 2rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #71717a;
}
/* Groups get the hidden attribute when none of their items match */
.cmd-group[hidden] { display: none; }
.cmd-group [data-bit-bh-command-group-heading] {
padding: 0.5rem 0.6rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #71717a;
}
.cmd-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 0.4rem;
font-size: 0.875rem;
color: #27272a;
cursor: pointer;
user-select: none;
}
/* data-selected marks the highlighted item (the aria-activedescendant target) */
.cmd-item[data-selected] { background: #eff6ff; color: #1d4ed8; }
.cmd-item[data-disabled] { opacity: 0.45; cursor: not-allowed; }
.cmd-item-icon { width: 1.25rem; flex-shrink: 0; text-align: center; }
.cmd-item-shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #71717a;
}
.cmd-item[data-selected] .cmd-item-shortcut { color: #60a5fa; }
/* BitBhCommandSeparator is removed while searching */
.cmd-separator { height: 1px; margin: 0.375rem -0.375rem; background: #e4e4e7; }
.cmd-spinner {
width: 1rem;
height: 1rem;
flex-shrink: 0;
border: 2px solid #e4e4e7;
border-top-color: #2563eb;
border-radius: 50%;
animation: cmd-spin 700ms linear infinite;
}
@keyframes cmd-spin {
to { transform: rotate(360deg); }
}
.cmd-item-body { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.cmd-item-title { font-weight: 500; }
.cmd-item-description { overflow: hidden; font-size: 0.75rem; color: #52525b; white-space: nowrap; text-overflow: ellipsis; }
.cmd-item[data-selected] .cmd-item-description { color: #1d4ed8; }Relevance sorting and highlight binding
Sort="true" ranks matches by relevance through the CSS order property (so the
list is a flex column) and Loop="true" wraps the arrow keys. @bind-Value
follows the highlighted item, and an item's Template receives whether it is highlighted —
here it reveals an ↵ hint. Type "bar" and watch the best matches move to the top.
<BitBhCommand Label="Go to file" Sort="true" Loop="true" @bind-Value="highlightedFile" class="cmd">
<div class="cmd-input-row">
<span class="cmd-input-icon" aria-hidden="true">⌕</span>
<BitBhCommandInput AutoFocus="false" class="cmd-input" placeholder="Go to file…" />
</div>
<BitBhCommandList class="cmd-list cmd-list-sorted">
<BitBhCommandEmpty class="cmd-empty">No matching files.</BitBhCommandEmpty>
@foreach (var file in files)
{
<BitBhCommandItem Value="@file" OnSelect='@(() => Run($"Open {file}"))' class="cmd-item">
<Template Context="highlighted">
<span class="cmd-item-icon" aria-hidden="true">@(file.EndsWith(".razor") ? "🧩" : "📄")</span>
@file
@if (highlighted)
{
<kbd class="cmd-kbd cmd-item-enter" aria-hidden="true">↵</kbd>
}
</Template>
</BitBhCommandItem>
}
</BitBhCommandList>
</BitBhCommand>
<span class="demo-output">Highlighted: <strong>@(highlightedFile ?? "(none)")</strong> · Last command: <strong>@(lastCommand ?? "(none)")</strong></span>
@code {
private readonly string[] files =
[
"App.razor", "BitBhAnchorOptions.cs", "BitBhCommand.cs", "BitBhCommandDialog.cs",
"BitBhCommandItem.cs", "BitBhMenubar.cs", "BitBhToggleGroup.cs", "BitBhToolbar.cs",
"CommandPage.razor", "MenubarPage.razor", "Program.cs", "ToolbarPage.razor",
];
private string? highlightedFile;
private string? lastCommand;
private void Run(string command) => lastCommand = command;
}/* All styles are consumer-authored — the library ships zero CSS */
.cmd {
display: flex;
flex-direction: column;
width: 100%;
max-width: 28rem;
overflow: hidden;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
/* Focus stays in the input while navigating, so ring the whole card */
.cmd:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.cmd-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border-bottom: 1px solid #e4e4e7;
}
.cmd-input-icon { color: #a1a1aa; }
/* BitBhCommandInput renders <input role="combobox"> */
.cmd-input {
flex: 1;
min-width: 0;
height: 2.75rem;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.9rem;
color: #18181b;
}
.cmd-input::placeholder { color: #a1a1aa; }
/* BitBhCommandList renders role="listbox" */
.cmd-list {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.375rem;
scroll-padding-block: 0.375rem;
}
/* BitBhCommandEmpty only renders while nothing matches */
.cmd-empty {
padding: 2rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #71717a;
}
/* Groups get the hidden attribute when none of their items match */
.cmd-group[hidden] { display: none; }
.cmd-group [data-bit-bh-command-group-heading] {
padding: 0.5rem 0.6rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #71717a;
}
.cmd-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 0.4rem;
font-size: 0.875rem;
color: #27272a;
cursor: pointer;
user-select: none;
}
/* data-selected marks the highlighted item (the aria-activedescendant target) */
.cmd-item[data-selected] { background: #eff6ff; color: #1d4ed8; }
.cmd-item[data-disabled] { opacity: 0.45; cursor: not-allowed; }
.cmd-item-icon { width: 1.25rem; flex-shrink: 0; text-align: center; }
.cmd-item-shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #71717a;
}
.cmd-item[data-selected] .cmd-item-shortcut { color: #60a5fa; }
/* BitBhCommandSeparator is removed while searching */
.cmd-separator { height: 1px; margin: 0.375rem -0.375rem; background: #e4e4e7; }
.cmd-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.3rem;
border: 1px solid #e4e4e7;
border-bottom-width: 2px;
border-radius: 0.3rem;
background: #fafafa;
font-family: inherit;
font-size: 0.68rem;
color: #52525b;
}
/* Sort="true" ranks results with the CSS order property —
the list (and group item containers) must be flex columns */
.cmd-list-sorted { display: flex; flex-direction: column; }
.cmd-item-enter { margin-left: auto; }