Building an Accessible Active Filter Bar in Blazor


Building an Accessible Active Filter Bar in Blazor

In my thirty years of writing software, I've seen countless teams underestimate filtering. It always starts out looking dead simple. Slap a few dropdowns on a form, query the database, and you're done, right? But then your users hit a wall trying to answer one fundamental question:

Why am I seeing these results?

An approval queue, audit log, case-management list, or project dashboard can have several filters applied at once. If those filters are buried inside a collapsed form, users have no idea why a specific record is missing. Worse, they struggle to remove just one filter without nuking the entire search and starting over.

This project demonstrates a reusable ActiveFilterBar component for Blazor WebAssembly. It displays exactly which filters are affecting a result list right now, lets users drop them individually, supports a one-click "clear all," and plays nicely with assistive technology.

What the Control Does

The control surfaces each active filter as a compact chip:

Status: Open      ×
Region: Southwest ×
Owner: David      ×

Right above those chips, my approval queue demo includes an Add a filter picker. The dropdown only lists fields that aren't already active. You pick a field, hit Apply, and the page updates the active filter collection while refreshing the results.

The component itself stays out of the data access game. It is laser-focused on the user experience of managing those active filters. The parent page owns the actual filter collection and the query:

<ActiveFilterBar Filters="@_filters"
                 Busy="@_loading"
                 OnRemove="RemoveFilterAsync"
                 OnClearAll="ClearFiltersAsync" />

That separation of concerns means you can drop this component into basically any data screen in your app.

Why Active Filters Deserve Their Own Control

A standard filter form answers, "What can I search by?" An active filter bar answers, "What is filtering my results right now?"

Those are two very different jobs.

Giving active filters their own dedicated UI gives your users:

  • Visibility: They can read the current query state without opening another panel.
  • Fast correction: They can kill a single bad filter without losing the rest of their work.
  • Context: Every list of results clearly states the criteria that built it.
  • Consistency: You can reuse the exact same interaction across queues, logs, reports, and dashboards.
  • Better support tickets: A user can tell your help desk exactly which filters are turned on when reporting a bug.

In business apps, this shines brightest when a search returns zero results. Instead of a dead end, the user immediately sees their next step: drop a chip or clear the board.

The Important Interaction Details

It's easy to build a naive version of this that fails basic usability checks. Here are a few details I made sure to get right in this implementation.

Native Buttons

Each action relies on a real <button type="button">. This automatically gives keyboard users the expected Enter and Spacebar behavior without us having to write custom key handlers.

The remove buttons also carry fully descriptive accessible names:

<button type="button"
        class="remove-button"
        aria-label="@($"Remove {filter.Label}: {filter.Value}")"
        @onclick="() => RemoveAsync(filter)">
    <span aria-hidden="true">&times;</span>
</button>

Because of this, a screen reader will actually announce "Remove Status: Open" instead of a useless "times" or "close."

A Labeled Filter Region

The active filters are grouped together inside a section with a generated heading ID:

<section class="filter-bar"
         tabindex="-1"
         aria-labelledby="@_headingId"
         aria-busy="@IsBusy.ToString().ToLowerInvariant()">

Notice the tabindex="-1". This keeps the region out of the normal Tab order, but allows our Blazor code to programmatically send focus there after a filter is removed.

Focus Restoration

Think about what happens when a user clicks a chip's remove button. That button immediately ceases to exist in the DOM. Without some intervention, keyboard focus gets lost or thrown back to the top of the document—a terrible experience.

To fix this, the component flags that focus needs to be restored, and then drops focus safely back onto the filter region after the render cycle completes:

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (!_restoreFocus || IsBusy)
    {
        return;
    }

    _restoreFocus = false;
    await _region.FocusAsync(preventScroll: true);
}

Live Status Updates

I also baked in a polite live region:

<span class="visually-hidden"
      role="status"
      aria-live="polite"
      aria-atomic="true">
    @StatusMessage
</span>

As the user interacts, this quietly updates to "1 active filter," "3 active filters," or "No filters are active," letting screen readers pick up the state change without blowing away whatever the user is currently focused on.

Busy-State Protection

In the demo, the parent simulates a database query with a short delay. While that's running, all remove, clear, and apply actions are temporarily disabled.

This stops impatient users from rapid-firing clicks and sending conflicting state updates while you're still trying to rebuild the grid.

The Parent Owns the Data

The ActiveFilterBar has absolutely no idea how approval requests are stored or queried. It just takes a read-only list and spits out strongly typed callbacks:

[Parameter, EditorRequired]
public IReadOnlyList<FilterToken> Filters { get; set; } = [];

[Parameter]
public EventCallback<FilterToken> OnRemove { get; set; }

[Parameter]
public EventCallback OnClearAll { get; set; }

The parent page is the boss. It handles its own state updates:

private Task RemoveFilterAsync(ActiveFilterBar.FilterToken filter) =>
    UpdateFiltersAsync(() => _filters.RemoveAll(item => item.Id == filter.Id));

private Task ClearFiltersAsync() =>
    UpdateFiltersAsync(() =>
    {
        _filters.Clear();
        _selectedFieldId = null;
    });

This is what makes the bar truly reusable. It works just as well sitting on top of an Entity Framework query as it does an API call or an in-memory list.

Why the Filter Picker is Separate

You might notice the demo splits things into two distinct controls:

  1. The picker adds a field that isn't currently active.
  2. The active bar removes or clears fields that are currently active.

Keeping these separate keeps the mental model simple. The picker doesn't secretly alter the query just because a dropdown value changed. You have to make a choice and explicitly click Apply.

The available options in the picker are calculated dynamically based on what's already in the active bar:

private IReadOnlyList<FilterOption> AvailableFields =>
    FilterOptions
        .Where(option => _filters.All(filter => filter.Id != option.Id))
        .ToList();

If the user manages to turn on every single available filter, the dropdown and Apply button disable themselves and display a clear message: All available fields are already being used. Never leave a control enabled if clicking it doesn't do anything useful.

Pros

  • Clear query visibility: Users understand exactly what they're looking at instantly.
  • Efficient management: Trashing one bad chip is way faster than reconstructing an entire form.
  • Strong accessibility foundation: Real buttons, ARIA labels, polite live regions, and focus management beat a pile of styled <div> tags every time.
  • Reusable: Perfect for audit logs, support desks, and reporting dashboards.
  • No JavaScript required: It relies entirely on Blazor event handling and ElementReference.FocusAsync.

Cons and Trade-offs

  • Screen real estate: It adds a horizontal band above your data. On cramped screens, you'll need to decide if it should stick to the top while scrolling or scroll out of view.
  • Long values get messy: Complex date ranges or giant strings make chips ugly. This demo visually truncates long text while keeping the full text in the accessible label.
  • Complex expressions: This simple token model (Field = Value) works great for standard searches, but if you need nested ORs and ANDs, you'll need a heavier data model.
  • Client-side only: This demo just keeps state in memory. In production, you'll likely want to sync this state to the URL or browser storage so users don't lose their place on a page refresh.

When I Would Use This Control

I pull this pattern out of the toolbox when:

  • A grid can be filtered by several different fields.
  • Users constantly tweak or remove single criteria.
  • The data is critical enough that users absolutely must understand why it's filtered the way it is.
  • Accessibility and keyboard navigation are hard requirements.

I'd skip it if there are only one or two static filters, or if vertical space is at an extreme premium.

Testing the Control

I've included a solid suite of xUnit and bUnit tests covering everything from basic rendering and empty states to ARIA announcements, busy-state lockouts, and theme transitions.

You can run the suite locally:

dotnet test .\FilterButtons.Tests\FilterButtons.Tests.csproj

Final Thoughts

An active filter bar is a deceptively small piece of UI that pays massive dividends in usability. It takes hidden state, pulls it out into the light, and gives your users a safe, predictable way to manipulate it.

But the chips are only the visual half of the story. A production-ready component demands accessible names, proper focus management, screen-reader announcements, and a clean separation from your data-access layer. Nailing those details is what turns a row of pretty badges into a rock-solid interaction model.


[source code]

Comments

Popular posts from this blog

Customizing PWA Manifest and Icons for a Polished User Experience 🚀

Offline-First Strategy with Blazor PWAs: A Complete Guide 🚀