Skip to content

Pattern

Search and filter

Narrowing a list without losing track of what has been narrowed. The hard part is not the filtering — it is making the current state visible, and always leaving a way back to everything.

Built fromInputDropdown MenuBadgeButtonEmptyState

The patternPermalink to this section

Search, filter, and try to produce no results — every state below is live.

6 of 6 projects

  • Q3 marketing siteDeployed
  • billing-serviceDeployed
  • search-indexerBuilding
  • legacy-webhooksFailed
  • docs-portalDeployed
  • analytics-pipelineFailed

PartsPermalink to this section

Five pieces, and the last two are the ones most often left out.

  • Search input — type="search", with a leading magnifier and a clear button once there is text.
  • Filter control — a dropdown of checkboxes, showing a count when filters are active.
  • Active filter badges — each removable on its own, so a filter can be undone without opening the menu.
  • Result count — announced politely, so the effect of a filter is perceivable without seeing the list.
  • Empty result state — echoes the query and offers to clear the filters.

Make the state visiblePermalink to this section

The most common failure in this pattern is a filter that is active but invisible, leaving someone convinced their data has disappeared.

FailedEurope

Doshow active filters as removable badges

Each badge states a filter and removes it when pressed — the state and the undo are the same control.

Don'thide filters inside the menu

A closed menu gives no hint that two filters are excluding most of the list. At minimum, show a count.

Doalways offer a way back

One control that returns to the unfiltered list, no matter how many filters are active.

Error: no projects found

Don'treport zero results as an error

No results is a normal outcome of filtering, not a failure. Use an empty state, not an error.

BehaviorPermalink to this section

  • Filter as the person types, debounced by about 200ms — no Search button for a client-side list.
  • For server-side search, keep the previous results visible while loading rather than flashing an empty list.
  • Announce the result count through a polite live region so screen reader users perceive the change.
  • Put the state in the URL so a filtered view can be shared and survives a refresh.
  • Escape clears the search field when it has focus and contains text.
  • Never reset filters on navigation without saying so — returning to a list should return to its state.
  • Preserve scroll position when a filter is removed, so someone does not lose their place.

Code examplePermalink to this section

app/projects/project-filters.tsx
"use client";

import { Badge, Button, Input } from "@borneo/react";
import { Search, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";

export function ProjectFilters({ total, shown }) {
  const router = useRouter();
  const params = useSearchParams();
  const query = params.get("q") ?? "";
  const statuses = params.getAll("status");

  // Filter state lives in the URL, so the view is shareable
  // and survives a refresh or a back navigation.
  function update(next) {
    const search = new URLSearchParams(params);
    if (next.query !== undefined) {
      next.query ? search.set("q", next.query) : search.delete("q");
    }
    router.replace(`?${search}`);
  }

  return (
    <div>
      <Input
        type="search"
        value={query}
        onChange={(event) => update({ query: event.target.value })}
        placeholder="Search projects"
        aria-label="Search projects"
        leadingIcon={<Search />}
      />

      {statuses.map((status) => (
        <Badge key={status} variant="brand" size="sm">
          {status}
        </Badge>
      ))}

      {/* Announced politely so the effect of filtering is perceivable */}
      <p role="status" aria-live="polite" className="type-caption">
        {shown} of {total} projects
      </p>
    </div>
  );
}

Responsive behaviorPermalink to this section

Below 640px the search field and the filter button stack, and the filter menu aligns to the right edge so it stays on screen.

6 of 6 projects

  • Q3 marketing siteDeployed
  • billing-serviceDeployed
  • search-indexerBuilding
  • legacy-webhooksFailed
  • docs-portalDeployed
  • analytics-pipelineFailed

Filters can become a sheet

When a product has more than three filter groups, replace the dropdown with a full-height Dialog on mobile. The badges stay in place, so the active state is still visible after it closes.

AccessibilityPermalink to this section

  • The search field is type="search" with a real label, even when the label is visually hidden.
  • The result count sits in a role="status" region so changes are announced without stealing focus.
  • Each filter badge is a button with a visually hidden “Remove … filter” name.
  • The filter menu uses menuitemcheckbox semantics and stays open while several are toggled.
  • Removing a filter never moves focus unexpectedly — focus goes to the next badge or to the filter button.
  • The clear button is reachable by keyboard and is not the only way to undo a single filter.
  • Colour is never the only indication of an active filter — the badge's presence is the signal.