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.
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
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 & 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.
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.
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_actionis 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
- 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.
- On Safari, confirm the manifest has no
omniboxkey and the popup opens by shortcut with the search field focused. - 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.
- Search for a title containing
&on Firefox and confirm it is shown as&, not&.
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.
Related
- Providing omnibox suggestions asynchronously — the shared index and ranking.
- Building a capability matrix for your extension — recording the omnibox as a capability.
- Rendering long lists in a popup — the popup side at scale.
- Omnibox and address bar integration — the parent guide.