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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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.

Searchable text for one settingFour layers of text associated with a setting: the visible label, the help text, the section it belongs to, and hidden keywords added for synonyms.Label"Colour theme"highest weightHelp text"Match your system or choose…"medium weightSection"Appearance"context for resultsKeywordsdata-keywords="dark night mode"synonyms, not shown
The keyword layer is invisible and does the most work — it is where "dark", "night" and "theme" all point to one setting.

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.

From keystroke to filtered pageTyping is debounced, the query is split into terms and scored against the DOM-built index, settings and empty sections are hidden, matches are highlighted and the result count is announced.input (debounced)120 msSplit into termsall must matchScore each settinglabel > keywords > helpthen update the pagehidden on non-matchesand empty sections<mark> in labelsbuilt as nodesrole=status countannounced, no focus move
Everything is derived from the rendered page — nothing to keep in sync when a setting is added.

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.

Settings found on the first try, by search designShare of settings-location tasks completed with the first query, for label-only matching, labels plus help text, and labels, help text and keyword synonyms.Labels only48 % first-tr…Labels + help text67 % first-tr…Labels + help + keywords86 % first-tr…
Synonyms do most of the work — users rarely search for the exact label text.

Cross-browser variation

  • Chrome / Edge: the options page is an ordinary extension page; everything above uses standard DOM APIs. Ctrl+F also works, but it cannot hide non-matching settings or search keywords.
  • Firefox: identical behaviour. In the embedded about:addons options 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-keywords are translated too. Put them in messages.json and set them at render time.

Verification

  1. Type a synonym that appears only in data-keywords and confirm the setting appears.
  2. Search for a term with no matches and confirm every section heading disappears and the status says “No settings found”.
  3. 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.

  1. 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.

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.

Other UI/UX Patterns & Interactive Components Resources