Mobile product filters that work with a keyboard
Build a mobile filter dialog with separate draft selections, keyboard focus handling, and clear Apply and Cancel behavior.
In this guide
- Open with context
- Edit draft choices
- Apply or cancel
- Return focus
- Understand results
A drawer that looks finished but traps the buyer
On a phone, the filter panel slides over the product list. Touch users can tap a checkbox and apply it. A keyboard user opens the same panel, tabs behind it into hidden product links, and cannot tell where the close control went. The layout is responsive, but the interaction is incomplete.
Start with the journey: open filters, understand the available choices, change them, apply or cancel, and return to the results. A mobile drawer that blocks the rest of the page should behave as a modal dialog. A nonmodal panel needs a different interaction model; do not mix their semantics accidentally.
Make focus part of the component contract
For a modal filter panel, move focus to a useful control when it opens, keep Tab navigation within it, support Escape, and return focus to the trigger when it closes. Provide a visible close button and an accessible name. The W3C modal dialog pattern describes these core expectations.
Use a proven dialog primitive where possible instead of rebuilding focus containment from scattered event handlers. The surrounding page should not remain interactable while presented as modal. Initial focus needs judgment: on a long filter panel, focusing a heading can provide context without scrolling directly to a distant checkbox.
Build a modal filter component
Add MobileFilters.tsx as a Next.js client component. Pass the available options, the currently applied option values, and an onApply callback. The component owns only draft selections while the dialog is open. The parent owns the product query, URL, and resulting product-count announcement.
The native dialog opens with showModal, confines modal interaction, and returns focus to the trigger on close. Give it a viewport-bounded height and internal scrolling in the theme stylesheet. Option values must be unique; labels can be longer human-readable text.
filters/MobileFilters.tsx tsx
"use client";
import { useId, useRef, useState } from "react";
type FilterOption = { value: string; label: string };
type Props = {
options: readonly FilterOption[];
applied: readonly string[];
onApply(values: string[]): void;
};
export function MobileFilters({ options, applied, onApply }: Props) {
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [draft, setDraft] = useState<string[]>([]);
const titleId = useId();
function open() {
setDraft([...applied]);
dialog.current?.showModal();
}
function close() { dialog.current?.close(); }
return <>
<button ref={trigger} type="button" onClick={open}>
Filters ({applied.length})
</button>
<dialog ref={dialog} aria-labelledby={titleId}
onClose={() => trigger.current?.focus()}
onCancel={() => setDraft([...applied])}>
<h2 id={titleId}>Filter products</h2>
<button type="button" autoFocus onClick={close}>Cancel</button>
<fieldset>
<legend>Product options</legend>
{options.map(option => <label key={option.value}>
<input type="checkbox" checked={draft.includes(option.value)}
onChange={event => {
const checked = event.currentTarget.checked;
setDraft(current => checked ? [...current, option.value]
: current.filter(value => value !== option.value));
}} />
{option.label}
</label>)}
</fieldset>
<button type="button" onClick={() => {
const allowed = new Set(options.map(option => option.value));
onApply([...new Set(draft)].filter(value => allowed.has(value)));
close();
}}>Apply filters</button>
</dialog>
</>;
}
// Native dialog supplies modal focus containment and Escape behavior.
// Give dialog a viewport-bounded height and overflow:auto in theme CSS.
// Parent owns URL/query updates and announces the resulting product count.
If 'in-stock' is applied, opening the dialog copies it into draft. Selecting another option changes only draft. Cancel or Escape leaves applied filters unchanged. Apply removes duplicate and unavailable option values, calls onApply with the selected values, and closes the dialog. Connect that callback to the existing storefront filter action.
Use ordinary controls for ordinary choices
A filter value is usually a checkbox or radio button with a real label. A collapsible category group needs an operable button that communicates expanded state. Avoid making a styled text label the only click target or using color alone to indicate selection.
Keep counts and disabled states understandable. If a value would yield no results, decide whether it remains available and explain any disabled choice. Long technical attribute values should wrap without pushing the checkbox off-screen. The code block, drawer, and product list must each stay within their intended scrolling boundaries at narrow widths.
Test with keys and real layout constraints
At a narrow viewport, open the panel with Enter and traverse it with Tab and Shift+Tab. Change selections, apply them, reopen, and cancel a different draft. Verify Escape and the visible Cancel button return focus to Filters without changing the applied query.
Repeat with long labels, many options, zero products, browser back navigation, and a zoomed layout. Check keyboard and screen-reader interaction in the supported browsers, including result-count announcements. Automated accessibility checks help find structural errors; the focus journey needs direct interaction too.
- Keep focus visible on every control.
- Give the trigger an understandable selected-filter count.
- Do not unexpectedly scroll the page when results update.
- Honor reduced motion for optional transitions.
Treat the empty result as part of the design
When filters produce no products, retain the active selections and provide an obvious way to revise them. A blank list should not force the buyer to remember which hidden filters caused it. Useful recovery matters as much as opening the panel smoothly.
An accessible filter experience also helps mouse and touch users because its states and choices are explicit. The design is complete when a buyer can enter, change, understand, and exit the interaction confidently, regardless of whether they operate the storefront with a finger, keyboard, or assistive technology.
References and further reading
Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.