Search and Filter in a Large Options Page
Add search to an extension options page with dozens of settings — an index built from the DOM, highlighting matches, keeping sections navigable, and announcing results accessibly.
Table of Contents
Options pages grow. The first release has five toggles; two years later there are fifty settings across six sections and users write support mail asking where a setting is that has been on the page all along. The browser’s own settings pages solved this with a search box that filters as you type, and an extension options page benefits from exactly the same thing. The implementation is small — the care is in keeping it fast, navigable and accessible. This guide is part of options page layouts.
What search needs to index
A setting is findable by more than its label. Users search for what they want to happen (“dark”), for what they saw in a support article (“sync interval”), and for words that appear only in the help text beneath a control.
Step-by-step
1. Mark up each setting as a searchable unit
1<section id="appearance" aria-labelledby="appearance-h">
2 <h2 id="appearance-h">Appearance</h2>
3
4 <div class="setting" data-keywords="dark night mode colours">
5 <label for="theme">Colour theme</label>
6 <select id="theme">…</select>
7 <p class="help" id="theme-help">Match your system or choose light or dark.</p>
8 </div>
9</section>
Execution context: the options page document. Wrapping each control, its label and its help in one element gives search a unit to show or hide. data-keywords carries synonyms that should match without cluttering the visible copy.
2. Build the index once, from the DOM
1function buildIndex(root) {
2 return [...root.querySelectorAll(".setting")].map((el) => {
3 const section = el.closest("section");
4 return {
5 el,
6 section,
7 label: el.querySelector("label")?.textContent.trim().toLowerCase() ?? "",
8 help: el.querySelector(".help")?.textContent.trim().toLowerCase() ?? "",
9 keywords: (el.dataset.keywords ?? "").toLowerCase(),
10 sectionName: section?.querySelector("h2")?.textContent.trim().toLowerCase() ?? "",
11 };
12 });
13}
Execution context: the options page, after the settings have rendered. Indexing from the DOM rather than from a separate list means the index can never drift from what is on the page — including translated labels, which is what makes search work in every locale your page supports.
3. Score and filter as the user types
1function score(entry, terms) {
2 let total = 0;
3 for (const t of terms) {
4 if (entry.label.includes(t)) total += 3;
5 else if (entry.keywords.includes(t)) total += 2;
6 else if (entry.help.includes(t) || entry.sectionName.includes(t)) total += 1;
7 else return 0; // every term must match somewhere
8 }
9 return total;
10}
11
12function applyFilter(index, query) {
13 const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
14 let shown = 0;
15 for (const entry of index) {
16 const hit = !terms.length || score(entry, terms) > 0;
17 entry.el.hidden = !hit;
18 if (hit) shown++;
19 }
20 for (const section of new Set(index.map((e) => e.section))) {
21 section.hidden = ![...section.querySelectorAll(".setting")].some((s) => !s.hidden);
22 }
23 return shown;
24}
Execution context: the options page. Hiding with the hidden attribute removes filtered settings from the accessibility tree as well as from view, so a screen reader does not wander into invisible controls. Hiding empty sections keeps headings from dangling above nothing.
4. Debounce input and announce the result count
1const input = document.querySelector("#settings-search");
2const status = document.querySelector("#search-status"); // role="status"
3let timer;
4
5input.addEventListener("input", () => {
6 clearTimeout(timer);
7 timer = setTimeout(() => {
8 const n = applyFilter(index, input.value);
9 status.textContent = input.value ? chrome.i18n.getMessage("searchCount", [String(n)]) : "";
10 }, 120);
11});
Execution context: the options page. A short debounce keeps typing smooth on a page with hundreds of settings; the role="status" element announces “4 settings found” without moving focus. The live-region technique is described in announcing dynamic updates to screen readers.
5. Highlight matches without breaking the label
1function highlight(labelEl, terms) {
2 const text = labelEl.dataset.original ??= labelEl.textContent;
3 labelEl.replaceChildren();
4 const rx = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "gi");
5 for (const part of text.split(rx)) {
6 if (!part) continue;
7 labelEl.append(rx.test(part) ? Object.assign(document.createElement("mark"), { textContent: part }) : part);
8 rx.lastIndex = 0;
9 }
10}
Execution context: the options page. Building nodes rather than an HTML string keeps the label safe even when it contains a translation with special characters. Storing the original text lets highlighting be undone exactly when the query is cleared.
6. Make results reachable from the keyboard
1input.addEventListener("keydown", (e) => {
2 if (e.key === "Enter") {
3 const first = index.find((x) => !x.el.hidden);
4 first?.el.querySelector("input, select, textarea, button")?.focus();
5 }
6 if (e.key === "Escape") { input.value = ""; applyFilter(index, ""); }
7});
Execution context: the options page. Enter jumping to the first matching control and Escape clearing the filter are the two behaviours users expect from every settings search they have used — worth the six lines.
Performance on very large pages
For most extensions the filter above runs in well under a millisecond. It starts to matter when an options page lists hundreds of per-site rules or a long keyword blocklist, where each rule is its own .setting element. Two adjustments keep it responsive.
First, separate settings from data. A list of 800 blocked sites is not 800 settings; it is one setting whose value is a list. Give it its own filter box scoped to that list and exclude it from the page-wide index, so typing “dark” does not iterate the blocklist.
Second, batch DOM writes. Toggling hidden on hundreds of elements in a loop triggers style recalculation once per frame at most, but reading layout in the same loop — for instance to scroll to the first match — forces a synchronous reflow each time. Collect the first match during the loop and scroll after it.
Search as a signal for what is missing
A settings search is also a small research instrument. Queries that return nothing are users telling you, in their own words, what they expected to find. Recording them locally — never remotely without consent — and surfacing them in a diagnostics export gives you a list of synonyms to add to data-keywords and, occasionally, features people assume exist.
1async function recordMiss(query) {
2 const { searchMisses = [] } = await chrome.storage.local.get("searchMisses");
3 searchMisses.unshift({ q: query.slice(0, 40), at: Date.now() });
4 await chrome.storage.local.set({ searchMisses: searchMisses.slice(0, 50) });
5}
Execution context: the options page, called when a non-empty query returns zero results after the debounce. Keeping it local and capped means it never becomes the kind of data collection a privacy policy would need to disclose — the boundary discussed in reporting errors without breaking your privacy policy.
Cross-browser variation
- Chrome / Edge: the options page is an ordinary extension page; everything above uses standard DOM APIs.
Ctrl+Falso works, but it cannot hide non-matching settings or search keywords. - Firefox: identical behaviour. In the embedded
about:addonsoptions frame, the page’s own search box is especially valuable because the browser’s find bar searches the whole add-ons page. - Safari: identical DOM behaviour; the options surface may be a separate window, which does not affect the implementation.
- All three: labels come from your locale files, so search works in every language you ship — provided the
data-keywordsare translated too. Put them inmessages.jsonand set them at render time.
Verification
- Type a synonym that appears only in
data-keywordsand confirm the setting appears. - Search for a term with no matches and confirm every section heading disappears and the status says “No settings found”.
- Confirm filtered settings are out of the accessibility tree:
1[...document.querySelectorAll(".setting")].filter((el) => el.hidden).length;
2// matches the number of non-matching settings
Execution context: the options page console. Then tab through the page and confirm focus never lands on a hidden control.
- With a screen reader, type a query and confirm the count is announced without focus leaving the search box.
FAQ
Should I use a fuzzy-matching library?
For a few dozen settings, substring matching over labels, help and keywords is enough and has no dependency. A fuzzy library helps once typos in long labels become a common failure.
Should search also look inside collapsed sections?
Yes — expand any section that contains a match. A match the user cannot see is worse than no match.
Where should the search box go?
At the top, above the section navigation, and focusable with / from anywhere on the page — the convention most settings pages follow.
Should search results link to a URL?
Yes, if the options page is a full tab. Updating the fragment to the first match’s id (#theme) makes results shareable in support conversations — “open options and search for sync” becomes a single link. In the embedded options frame, fragments are unreliable, which is one more reason large settings pages belong in a tab.
Related
- Building a tabbed options page layout — search across tabs, not just within one.
- Accessible form controls for extension settings — the controls search leads to.
- Embedded versus full-page options declarations — when a page is large enough to need this.
- Options page layouts — the parent guide.