Omnibox Support and Alternatives Across Browsers

Ship an omnibox feature to Chrome, Firefox and Safari — Firefox's plain-text descriptions and icon, Safari's missing API, and a popup search that shares one index and one resolver.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

An omnibox keyword works on Chrome and Firefox and does not exist on Safari. Even between the two that support it, suggestions look different: Chrome renders <match> and <dim> markup, Firefox shows those tags as literal text. A cross-browser extension therefore needs one search core — index, ranking, resolver — and two front ends: the omnibox where it exists, and a keyboard-reachable popup search everywhere, which doubles as the only interface on Safari. This guide is part of omnibox and address bar integration.

What each engine offers

Omnibox support by engineKeyword registration, suggestion markup, default suggestion, dispositions, icon display and the recommended fallback compared across Chrome, Firefox and Safari.FeatureChrome / EdgeFirefoxSafariomnibox.keywordYesYesNoDescription markup<match> <dim> <url>Plain textsetDefaultSuggestionYesYesDispositions on EnterThreeThreeExtension icon in the barName shownIcon + nameFallbackPopup searchPopup searchPopup search (primary)
Two front ends cover all three: the omnibox where it exists and a popup search that also serves as Safari's only interface.

Step-by-step

1. Put search in a shared core

1// search-core.js — no omnibox or DOM dependencies
2export async function query(text, limit = 6) { /* index lookup + ranking */ }
3export function resolve(text) { /* doc: / action: / typed → target */ }
4export function describe(hit) {
5  return { title: hit.title, section: hit.section, url: hit.url };
6}

Execution context: a module imported by the service worker and by the popup. The index, ranking and resolver described in providing omnibox suggestions asynchronously and handling omnibox input entered navigation move here unchanged; only rendering is front-end specific.

2. Format descriptions per engine

1const supportsMarkup = !(typeof browser !== "undefined" && browser.runtime.getURL("").startsWith("moz-extension:"));
2
3function omniboxDescription(d) {
4  return supportsMarkup
5    ? `<match>${escapeXml(d.title)}</match> <dim>— ${escapeXml(d.section)}</dim>`
6    : `${d.title}${d.section}`;
7}

Execution context: the service worker. This is one of the few places where detecting the engine by a property of the runtime is justified — there is no capability probe for “renders markup”. Using the extension URL scheme (moz-extension: versus chrome-extension:) is more robust than parsing the user agent. Plain text on Firefox must not be XML-escaped, or users see &amp; literally.

3. Register the omnibox only where it exists

1const api = globalThis.browser ?? globalThis.chrome;
2
3if (api.omnibox) {
4  api.omnibox.onInputChanged.addListener((text, suggest) => onChanged(text, suggest));
5  api.omnibox.onInputEntered.addListener((text, disposition) => onEntered(text, disposition));
6}

Execution context: the background context. On Safari api.omnibox is undefined and nothing is registered. The manifest must also omit the omnibox key for Safari — the per-target generation in generating a manifest per browser target handles that.

4. Build the popup search as the universal front end

1<form id="search" role="search">
2  <label for="q" class="visually-hidden">Search the docs</label>
3  <input id="q" type="search" autocomplete="off" autofocus>
4  <ul id="results" role="listbox" aria-label="Results"></ul>
5</form>
 1import { query, resolve } from "./search-core.js";
 2
 3q.addEventListener("input", async () => {
 4  const hits = await query(q.value);
 5  results.replaceChildren(...hits.map((h, i) => option(h, i)));
 6});
 7
 8document.querySelector("#search").addEventListener("submit", async (e) => {
 9  e.preventDefault();
10  const target = await resolve(selectedContent() ?? q.value);
11  await chrome.tabs.create({ url: target.url });
12  window.close();
13});

Execution context: the popup. autofocus on the search field means the popup opens ready to type — which, combined with a keyboard shortcut to open it, makes the popup almost as fast as the omnibox. It runs the same query and resolve as the omnibox path, so results are identical.

5. Give the popup a shortcut on every engine

1{
2  "commands": {
3    "_execute_action": {
4      "suggested_key": { "default": "Alt+Shift+D" },
5      "description": "__MSG_cmdSearch__"
6    }
7  }
8}

Execution context: parsed at install, on all three engines. On Safari this is the primary way into search; on Chrome and Firefox it is an alternative for users who never learn the keyword. The shortcut rules are in the _execute_action command and the four-shortcut limit.

One search core, two front endsThe shared core provides query and resolve to both the omnibox handlers on Chrome and Firefox and the popup search on every engine, with engine-specific description formatting only in the omnibox path.search-core.jsindex, rank, resolveOmnibox handlersChrome, FirefoxPopup searchall enginesformatting differs per surfaceMarkup descriptionsChromePlain descriptionsFirefoxDOM rowspopup, textContent
Safari users get the same results through the popup — only the entry point differs.

Making the popup fast enough to be a real alternative

On Safari the popup is not a fallback — it is the product. It has to open and be ready to type as quickly as possible, which puts the same constraints on it as the omnibox’s suggestion path.

The index should be readable without waking the worker: store it in chrome.storage.local and let the popup query it directly, so a cold worker never sits between the keystroke and the result. Keep the popup’s own bundle small, because it is parsed on every open. And restore the last query and scroll position when the user reopens it, since a user who clicks away to check something and comes back expects to find their search where they left it — the pattern from why the popup closes and how to work with it.

Done well, the difference in speed between the two front ends is small enough that some Chrome users prefer the popup, because it shows more detail per result than the omnibox’s single line of text. That is a reasonable outcome: offer both, and let usage decide which one you invest in.

Time from intent to results by front endMeasured time from deciding to search to seeing results for the omnibox keyword, the popup opened by shortcut, the popup opened by clicking the toolbar, and the popup with a cold worker path.Omnibox keyword900 msPopup via shortcut, storage query1300 msPopup via toolbar click2100 msPopup waiting on a cold worker2600 ms
A shortcut-opened popup that queries storage directly is within half a second of the omnibox.

Testing two front ends without doubling the work

The architecture above only pays off if the shared core is tested once and the front ends are tested thinly. Testing the full search experience separately in the omnibox and the popup, on three engines, is six test surfaces for what is essentially one feature.

The split that works is: exhaustive unit tests on query and resolve in Node, covering ranking order, escaping, typed-input resolution and actions; one end-to-end test per front end per engine, asserting only that typing produces results and Enter opens the expected URL. The omnibox itself cannot be driven by Playwright — the address bar is browser chrome, not page content — so the omnibox end-to-end test calls the registered listeners directly from the worker with a fake suggest callback, while the popup test drives the real popup page loaded as a tab.

1// worker-level omnibox test, run through the extension's own test hook
2const seen = [];
3await onChanged("alarms", (s) => seen.push(...s));
4assert.ok(seen[0].content.startsWith("doc:"));

Execution context: the service worker, invoked from a Playwright test through serviceWorker.evaluate, as set up in driving service worker state from a test. It exercises exactly the code the address bar would call, without needing the address bar.

Cross-browser variation

  • Chrome / Edge: full omnibox with markup; the popup search is optional for users who prefer it.
  • Firefox: omnibox with plain-text descriptions and the extension’s icon shown in the address bar during a session. Firefox users are more likely to discover the keyword through the add-on’s listing, so mention it there.
  • Safari: no omnibox; the popup search opened by _execute_action is the only interface. Hide any omnibox hints in Safari builds.
  • All three: the shared core behaves identically everywhere — test it in Node once, and test only the two thin front ends per engine.

Verification

  1. On Chrome and Firefox, type the keyword and a query and confirm identical results, rendered with markup on Chrome and as plain text on Firefox.
  2. On Safari, confirm the manifest has no omnibox key and the popup opens by shortcut with the search field focused.
  3. Confirm both front ends resolve identically:
1const [a, b] = await Promise.all([query("alarms"), query("alarms")]);
2JSON.stringify(a) === JSON.stringify(b);
3// true — and the popup's rendered order should match the omnibox's

Execution context: any extension context. The point is less the equality than confirming both paths import the same module rather than a copy.

  1. Search for a title containing & on Firefox and confirm it is shown as &, not &amp;.

FAQ

Is there any address-bar integration on Safari?

No extension API for it. Safari users can add a custom search engine themselves, but an extension cannot register one.

Should the popup search replace the omnibox on Chrome too?

No — offer both. They serve different moments: the keyword when the user is already in the address bar, the popup when they are not.

Can a Firefox build show richer suggestions?

Not through markup. Keep descriptions short and put the most distinguishing words first, since there is no dimmed secondary text to lean on.

How do I tell Safari users the keyword does not exist for them?

Do not mention it at all in Safari builds. Strip omnibox hints from the Safari popup and onboarding, and describe the keyboard shortcut instead — telling users about a feature they cannot have is worse than silence.

Other UI/UX Patterns & Interactive Components Resources