Accessibility Utilities
The low-level building blocks the components are made of — hidden labels, separators, live regions, announcements, hotkeys, focus scopes, reading direction and roving focus.
Visually hidden
BitBhVisuallyHidden keeps text in the accessibility tree while hiding it — the names of icon-only buttons.
Focusable="true" reveals it while focused: Tab into this demo to uncover the skip link.
@* Skip link: hidden until it receives keyboard focus *@
<BitBhVisuallyHidden Focusable="true">
<a href="tw/accessibility#a11y-skip-target"
class="inline-block rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white no-underline"
@onclick="SkipToContent" @onclick:preventDefault>Skip to content</a>
</BitBhVisuallyHidden>
@* Icon-only button: the hidden text is the accessible name *@
<button type="button"
class="inline-flex size-9 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-[1.1rem]" viewBox="0 0 24 24" ... aria-hidden="true" focusable="false">…</svg>
<BitBhVisuallyHidden>Edit</BitBhVisuallyHidden>
</button>
<div id="a11y-skip-target" tabindex="-1" @ref="skipTarget"
class="rounded-lg border border-dashed border-zinc-300 px-4 py-3 text-sm focus:outline-2 focus:outline-blue-600">
Main content — the skip link moves focus here.
</div>
@code {
private ElementReference skipTarget;
private async Task SkipToContent() => await skipTarget.FocusAsync();
}Separator
Semantic separators render role="separator" (+ aria-orientation="vertical");
Decorative="true" renders role="none".
Style both with data-orientation-*:.
bit Bheadless
Unstyled, accessible components for Blazor.
<div class="w-full max-w-sm rounded-xl border border-zinc-200 bg-white px-5 py-4">
<p class="m-0 font-semibold">bit Bheadless</p>
<p class="m-0 mt-0.5 text-sm text-zinc-500">Unstyled, accessible components for Blazor.</p>
@* role="separator" *@
<BitBhSeparator class="shrink-0 bg-zinc-200 data-orientation-horizontal:my-3.5 data-orientation-horizontal:h-px data-orientation-horizontal:w-full data-orientation-vertical:h-full data-orientation-vertical:w-px" />
<div class="flex h-5 items-center gap-3 text-sm">
<span>Docs</span>
@* role="separator" aria-orientation="vertical" *@
<BitBhSeparator Orientation="BitBhOrientation.Vertical" class="shrink-0 bg-zinc-200 data-orientation-vertical:h-full data-orientation-vertical:w-px" />
<span>Source</span>
<BitBhSeparator Orientation="BitBhOrientation.Vertical" class="shrink-0 bg-zinc-200 data-orientation-vertical:h-full data-orientation-vertical:w-px" />
<span>Changelog</span>
</div>
</div>
@* Purely visual: role="none" *@
<div class="flex w-full max-w-sm items-center gap-3 text-xs font-semibold uppercase text-zinc-500">
<BitBhSeparator Decorative="true" class="h-px flex-1 bg-zinc-200" />
or
<BitBhSeparator Decorative="true" class="h-px flex-1 bg-zinc-200" />
</div>Live regions
BitBhLiveRegion exists from the first render, so content changes are announced — politely (role="status")
or assertively (role="alert"). VisuallyHidden="true" announces without showing.
<BitBhButton OnClick="SaveDraft" class="...">Save draft</BitBhButton>
@* Polite: role="status" aria-live="polite" *@
<BitBhLiveRegion class="text-sm text-green-700">@draftStatus</BitBhLiveRegion>
<BitBhButton OnClick="ToggleConnection" class="...">
@(offline ? "Reconnect" : "Simulate connection loss")
</BitBhButton>
@* Assertive: role="alert" aria-live="assertive" — stays rendered while empty *@
<BitBhLiveRegion Politeness="BitBhPoliteness.Assertive"
class="text-sm text-red-800 not-empty:rounded-lg not-empty:border not-empty:border-red-200 not-empty:bg-red-50 not-empty:px-3 not-empty:py-2">@alertText</BitBhLiveRegion>
@code {
private string? draftStatus;
private string? alertText;
private bool offline;
private void SaveDraft() => draftStatus = $"Draft saved at {DateTime.Now:HH:mm:ss}.";
private void ToggleConnection()
{
offline = !offline;
alertText = offline ? "Connection lost. Changes will sync when you are back online." : null;
}
}Announcer
Inject IBitBhAnnouncer and call AnnounceAsync for fire-and-forget messages — it writes to one shared,
visually hidden live region. The log only mirrors what a screen reader hears.
- Nothing announced yet.
@inject IBitBhAnnouncer Announcer
@foreach (var product in Products)
{
<div class="flex min-w-40 flex-col gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-3.5 text-sm">
<strong>@product</strong>
<BitBhButton OnClick="() => AddToCartAsync(product)" class="...">Add to cart</BitBhButton>
</div>
}
<BitBhButton OnClick="EmptyCartAsync" class="...">Empty cart (assertive)</BitBhButton>
@code {
private static readonly string[] Products = ["Notebook", "Fountain pen", "Desk lamp"];
private int cartCount;
private async Task AddToCartAsync(string product)
{
cartCount++;
await Announcer.AnnounceAsync($"{product} added to cart. {cartCount} item(s) in your cart.");
}
private async Task EmptyCartAsync()
{
cartCount = 0;
await Announcer.AnnounceAsync("Cart emptied.", BitBhPoliteness.Assertive);
}
}Hotkeys
BitBhHotkey registers a global shortcut while rendered (mod = ⌘ on Apple, Ctrl elsewhere).
Shortcuts without Ctrl / ⌘ are ignored in text fields unless AllowInInputs is set. Try Ctrl+K and ?.
<BitBhHotkey Keys="mod+k" OnTriggered="FocusSearchAsync" />
<BitBhHotkey Keys="shift+?" OnTriggered="ToggleShortcuts" />
<div class="relative w-full max-w-xs">
<input @ref="searchInput" type="search" placeholder="Search components…"
aria-label="Search components" aria-keyshortcuts="Control+K Meta+K"
class="w-full rounded-md border border-zinc-300 bg-white py-2 pl-3 pr-18 text-sm focus-visible:outline-2 focus-visible:outline-blue-600" />
<span class="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 gap-1" aria-hidden="true">
<kbd class="rounded border border-b-2 border-zinc-300 bg-zinc-50 px-1.5 font-mono text-xs">Ctrl</kbd>
<kbd class="rounded border border-b-2 border-zinc-300 bg-zinc-50 px-1.5 font-mono text-xs">K</kbd>
</span>
</div>
<BitBhButton OnClick="ToggleShortcuts" aria-expanded="@(showShortcuts ? "true" : "false")" aria-keyshortcuts="Shift+?" class="...">
Keyboard shortcuts <kbd class="...">?</kbd>
</BitBhButton>
@if (showShortcuts)
{
<section aria-label="Keyboard shortcuts" class="...">…</section>
}
@code {
private ElementReference searchInput;
private bool showShortcuts;
private async Task FocusSearchAsync() => await searchInput.FocusAsync();
private void ToggleShortcuts() => showShortcuts = !showShortcuts;
}Focus scope and focus trap
BitBhFocusScope auto-focuses its content, traps (Trapped) or wraps (Loop) Tab, and restores focus when it deactivates.
BitBhFocusTrap = trapped by default. Save, Cancel or Escape return focus to "Edit profile".
<BitBhButton OnClick="StartEditing" aria-expanded="@(editing ? "true" : "false")" class="...">
Edit profile
</BitBhButton>
@if (editing)
{
@* Auto-focuses the first field, traps Tab, and restores focus to
"Edit profile" when removed. <BitBhFocusTrap> = Trapped by default. *@
<BitBhFocusScope Trapped="true" RestoreFocus="true" aria-label="Edit profile" role="group" @onkeydown="OnScopeKeyDown"
class="flex flex-col gap-3 rounded-lg border-2 border-dashed border-zinc-300 p-4 outline-none data-state-active:border-blue-600 data-state-active:bg-blue-50">
<label class="flex flex-col gap-1 text-xs font-semibold">
<span>Name</span>
<input @bind="draftName" class="rounded-md border border-zinc-300 px-3 py-2 text-sm font-normal" />
</label>
<label class="flex flex-col gap-1 text-xs font-semibold">
<span>Email</span>
<input type="email" @bind="draftEmail" class="rounded-md border border-zinc-300 px-3 py-2 text-sm font-normal" />
</label>
<div class="flex justify-end gap-2">
<button type="button" @onclick="CancelEditing" class="...">Cancel</button>
<button type="button" @onclick="SaveProfile" class="...">Save</button>
</div>
</BitBhFocusScope>
}
@code {
private bool editing;
private void OnScopeKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape") editing = false;
}
// StartEditing / SaveProfile / CancelEditing toggle `editing`.
}Reading direction
BitBhDirectionProvider cascades the direction to every component below it: in RTL, ArrowLeft moves to the next radio or tab.
Set the HTML dir attribute too so the layout mirrors.
<div dir="@direction.ToAttribute()" class="flex flex-col gap-4 rounded-xl border border-zinc-200 bg-zinc-50 p-4"> @* flips the layout *@
<BitBhDirectionProvider Dir="direction"> @* flips the arrow keys *@
<BitBhRadioGroup Vertical="false" DefaultValue="m" aria-label="Size"
class="inline-flex gap-1 self-start rounded-lg border border-zinc-200 bg-white p-0.5">
<BitBhRadio Value="s" class="rounded-md px-3.5 py-1.5 text-sm text-zinc-600 data-state-checked:bg-blue-600 data-state-checked:text-white">Small</BitBhRadio>
<BitBhRadio Value="m" class="rounded-md px-3.5 py-1.5 text-sm text-zinc-600 data-state-checked:bg-blue-600 data-state-checked:text-white">Medium</BitBhRadio>
<BitBhRadio Value="l" class="rounded-md px-3.5 py-1.5 text-sm text-zinc-600 data-state-checked:bg-blue-600 data-state-checked:text-white">Large</BitBhRadio>
</BitBhRadioGroup>
<BitBhTabGroup>
<BitBhTabList aria-label="Project" class="flex gap-1 border-b border-zinc-200">
<BitBhTab class="border-b-2 border-transparent px-3.5 py-2 text-sm text-zinc-500 data-state-active:border-blue-600 data-state-active:text-blue-600">Overview</BitBhTab>
<BitBhTab class="...">Activity</BitBhTab>
<BitBhTab class="...">Settings</BitBhTab>
</BitBhTabList>
<BitBhTabPanels>
<BitBhTabPanel class="pt-3 text-sm text-zinc-600">…</BitBhTabPanel>
<BitBhTabPanel class="pt-3 text-sm text-zinc-600">…</BitBhTabPanel>
<BitBhTabPanel class="pt-3 text-sm text-zinc-600">…</BitBhTabPanel>
</BitBhTabPanels>
</BitBhTabGroup>
</BitBhDirectionProvider>
</div>
@code {
private BitBhDirection direction = BitBhDirection.Rtl;
}Roving focus
BitBhRovingFocusGroup + BitBhRovingFocusItem give your own composite widgets a roving tabindex:
one Tab stop, arrow keys / Home / End between items, disabled items skipped. Give the group the role that fits — here a toolbar of toggle chips.
<BitBhRovingFocusGroup role="toolbar" aria-label="Filter by tag" class="flex flex-wrap gap-2">
@foreach (var tag in Tags)
{
<BitBhRovingFocusItem Disabled="@(tag == "Archived")"
aria-pressed="@(selectedTags.Contains(tag) ? "true" : "false")"
@onclick="() => ToggleTag(tag)"
class="inline-flex items-center rounded-full border border-zinc-300 bg-white px-3.5 py-1.5 text-sm text-zinc-700
aria-pressed:border-blue-600 aria-pressed:bg-blue-50 aria-pressed:text-blue-700
data-disabled:cursor-not-allowed data-disabled:opacity-40
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
@tag
</BitBhRovingFocusItem>
}
</BitBhRovingFocusGroup>
@code {
private static readonly string[] Tags = ["Design", "Engineering", "Marketing", "Archived", "Support"];
private readonly HashSet<string> selectedTags = new() { "Engineering" };
private void ToggleTag(string tag)
{
if (!selectedTags.Remove(tag)) selectedTags.Add(tag);
}
}