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.
The patternPermalink to this section
Search, filter, and try to produce no results — every state below is live.
6 of 6 projects
- Q3 marketing site
- billing-service
- search-indexer
- legacy-webhooks
- docs-portal
- analytics-pipeline
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.
Do — show 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't — hide filters inside the menu
A closed menu gives no hint that two filters are excluding most of the list. At minimum, show a count.
Do — always offer a way back
One control that returns to the unfiltered list, no matter how many filters are active.
Error: no projects found
Don't — report 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
"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 site
- billing-service
- search-indexer
- legacy-webhooks
- docs-portal
- analytics-pipeline
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.