Search and Filter UI in a Popup
Build fast, accessible search and filtering inside an extension popup: an autofocused search box, instant filtering with debouncing, filter chips, highlighting matches, keyboard navigation of results, remembering the last query, and empty results.
Table of Contents
The popup lists 600 saved pages. Users scroll, give up, and open the full library tab instead — which takes longer than whatever they were trying to do. A search box at the top of the popup, focused on open, filtering as they type, with a couple of filter chips for the most common distinctions (unread, this site), turns the popup into the fastest way to find anything. Done well, it feels instant; done poorly — filtering on every keystroke across hundreds of DOM nodes, losing the query when the popup closes, trapping keyboard users in the input — it feels broken. This guide builds the good version. It belongs to popup interface design.
The parts of popup search
Popup search has a short life: the user opens the popup, types a few characters, picks a result, and the popup closes. Optimise for that path. The search box is focused on open so typing starts immediately. Filtering runs against an in-memory array, not the DOM, and re-renders a bounded list. Filters narrow by common facets with one click and combine with the query. Results are keyboard-navigable from the search box with the arrow keys and openable with Enter. State — the last query and filters — is restored briefly so a user who closed the popup by accident does not start over.
Step-by-step: popup search
1. Markup with an accessible combobox
1<header>
2 <input id="q" type="search" role="combobox" aria-controls="results" aria-expanded="true"
3 aria-autocomplete="list" aria-activedescendant="" autocomplete="off" autofocus
4 aria-label="Search saved pages" placeholder="Search saved pages">
5 <div role="group" aria-label="Filters" class="chips">
6 <button type="button" aria-pressed="false" data-filter="unread">Unread</button>
7 <button type="button" aria-pressed="false" data-filter="thisSite">This site</button>
8 </div>
9</header>
10<ul id="results" role="listbox" aria-label="Results"></ul>
11<p id="count" role="status" class="visually-hidden"></p>
Execution context: the popup page. The combobox pattern keeps focus in the input while arrow keys move a virtual cursor through the listbox, so typing and navigating never conflict. Filter chips are toggle buttons (aria-pressed). A hidden status element announces result counts. Placeholders need localising like everything else.
2. Load items once and index them
1// popup.js
2const { items = [] } = await chrome.storage.local.get("items");
3const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
4const currentHost = safeHost(tab?.url);
5const indexed = items.map((it) => ({ ...it, _t: it.title.toLowerCase(), _h: safeHost(it.url) }))
6 .sort((a, b) => b.savedAt - a.savedAt);
Execution context: the popup. Lower-casing once at load avoids doing it per keystroke. Reading the active tab’s host enables the “This site” chip. For thousands of items, load from IndexedDB or ask the worker for a prebuilt index, as in caching an omnibox search index.
3. Filter by query and chips together
1const state = { q: "", unread: false, thisSite: false, active: 0 };
2
3function filtered() {
4 const terms = state.q.toLowerCase().split(/\s+/).filter(Boolean);
5 return indexed.filter((it) =>
6 (!state.unread || !it.read) &&
7 (!state.thisSite || it._h === currentHost) &&
8 terms.every((t) => it._t.includes(t) || it._h?.includes(t)));
9}
Execution context: the popup. Every term must match (AND), which narrows results as users type more words — what people expect. Matching the host lets “github” find items from github.com even when the title doesn’t say so. Chips combine with the query rather than replacing it.
4. Render a bounded, highlighted list
1const list = document.querySelector("#results"), q = document.querySelector("#q");
2let results = [];
3
4function render() {
5 results = filtered();
6 const shown = results.slice(0, 50);
7 state.active = Math.min(state.active, Math.max(shown.length - 1, 0));
8 list.replaceChildren(...shown.map((it, i) => {
9 const li = document.createElement("li");
10 li.id = `r-${it.id}`; li.role = "option";
11 li.setAttribute("aria-selected", String(i === state.active));
12 li.append(highlight(it.title, state.q));
13 li.addEventListener("click", () => open(it));
14 return li;
15 }));
16 q.setAttribute("aria-activedescendant", shown[state.active] ? `r-${shown[state.active].id}` : "");
17 document.querySelector("#count").textContent = chrome.i18n.getMessage("resultCount", [String(results.length)]);
18 if (!results.length) list.replaceChildren(emptyState());
19}
20
21function highlight(text, query) {
22 const frag = document.createDocumentFragment();
23 const term = query.trim().split(/\s+/)[0]?.toLowerCase();
24 const i = term ? text.toLowerCase().indexOf(term) : -1;
25 if (i < 0) { frag.append(text); return frag; }
26 frag.append(text.slice(0, i), Object.assign(document.createElement("mark"), { textContent: text.slice(i, i + term.length) }), text.slice(i + term.length));
27 return frag;
28}
Execution context: the popup. Rendering at most 50 rows keeps each keystroke fast regardless of library size. Highlighting builds DOM nodes from text pieces — never innerHTML with titles. The result count is announced politely for screen reader users. Use plural-aware messages for the count, as in plurals and placeholders in messages.json.
5. Wire input, chips and keys
1let t;
2q.addEventListener("input", () => {
3 clearTimeout(t);
4 t = setTimeout(() => { state.q = q.value; state.active = 0; render(); saveQuery(); }, indexed.length > 500 ? 50 : 0);
5});
6document.querySelector(".chips").addEventListener("click", (e) => {
7 const b = e.target.closest("[data-filter]"); if (!b) return;
8 state[b.dataset.filter] = !state[b.dataset.filter];
9 b.setAttribute("aria-pressed", String(state[b.dataset.filter]));
10 state.active = 0; render(); q.focus();
11});
12q.addEventListener("keydown", (e) => {
13 const n = Math.min(results.length, 50);
14 if (e.key === "ArrowDown" && n) { state.active = (state.active + 1) % n; render(); e.preventDefault(); }
15 if (e.key === "ArrowUp" && n) { state.active = (state.active - 1 + n) % n; render(); e.preventDefault(); }
16 if (e.key === "Enter" && results[state.active]) open(results[state.active]);
17});
Execution context: the popup. A short debounce only for large lists keeps small lists perfectly instant. After toggling a chip, focus returns to the search box so the user can keep typing. Escape closes the popup natively.
6. Remember the query briefly
1async function saveQuery() {
2 await chrome.storage.session.set({ popupSearch: { q: state.q, unread: state.unread, thisSite: state.thisSite, at: Date.now() } });
3}
4const { popupSearch } = await chrome.storage.session.get("popupSearch");
5if (popupSearch && Date.now() - popupSearch.at < 60_000) {
6 Object.assign(state, popupSearch); q.value = state.q; q.select();
7}
Execution context: the popup. Restoring the last query within a minute helps users who closed the popup by accident, while older queries are dropped so each fresh visit starts clean. Selecting the restored text means typing replaces it. See preserving popup state when it closes.
7. Make empty results useful
When nothing matches, say so and offer the next step: clear filters (if any are active), search the full library tab, or save the current page. “No results for ‘reac’ in unread pages — Show all pages” fixes the most common cause in one click.
8. Open results the way the user expects
1async function open(item, e) {
2 const newTab = e?.ctrlKey || e?.metaKey || e?.button === 1;
3 const [existing] = await chrome.tabs.query({ url: item.url, currentWindow: true });
4 if (existing && !newTab) await chrome.tabs.update(existing.id, { active: true });
5 else if (newTab) await chrome.tabs.create({ url: item.url, active: false });
6 else await chrome.tabs.update({ url: item.url });
7 if (!newTab) window.close();
8}
Execution context: the popup. Enter and a plain click open the result in the current tab, or switch to it if it is already open, then close the popup. Ctrl/⌘-click or middle-click opens it in a background tab and keeps the popup open, so users can open several results in a row. Querying tabs by URL requires the tabs permission or host access; without it, skip the switch-to-existing step.
Common mistakes
- Filtering by hiding DOM nodes. Slow with hundreds of rows.
- No autofocus. Users must click before typing.
- Moving focus into the list. Typing stops working.
- Rendering every match. Bound the list.
innerHTMLfor highlights. Page titles are untrusted.
Cross-browser variation
- Chrome / Edge:
autofocusworks in popups;storage.sessionavailable. - Firefox: same patterns;
storage.sessionin recent versions — fall back to an in-memory value in the worker otherwise. - Safari: popovers focus on open; test
autofocus, and focus programmatically after the first frame if needed.
Verification
- Open the popup and type immediately — characters land in the search box.
- With 2,000 items, confirm keystrokes stay responsive.
- Navigate results with arrows and open with Enter, with a screen reader announcing each option.
- Close and reopen within a minute and confirm the query is restored.
FAQ
Should search include page content, not just titles?
If you store content, yes — but index it ahead of time; scanning full text per keystroke is too slow.
Fuzzy or exact matching?
Substring matching with multiple terms works well for titles. Add fuzzy matching only if users mistype often.
How many filter chips?
Two or three. More belong in the full library view.
Should results be sorted by relevance or recency?
For short queries, recency usually works better because many items match equally. Once the query is specific, rank title-prefix matches first. The ranking approach in ranking omnibox suggestions transfers directly.
Related
- Rendering long lists in a popup — virtualisation.
- Building a command palette in an extension — the same combobox pattern.
- Popup loading and empty states — empty results.
- Popup interface design — the parent topic.