Opening the Options Page Programmatically

Use runtime.openOptionsPage from a popup, menu or worker, focus an already-open tab instead of duplicating it, and deep-link to a specific section without a router.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

The user clicks “Settings” in your popup three times and ends up with three settings tabs, none of which is the one they were reading. Or the click does nothing at all, because the call came from a context where the API is unavailable. Opening your own options page sounds like it should be one line, and it is — but only if you know which line, and what it does about a tab that is already open. This page is part of Options Page Configuration and covers the entry points, the deduplication, and deep-linking into a section.

Root cause: two manifest keys, two different behaviours

options_page opens a full tab. options_ui with open_in_tab: false opens an embedded dialog inside the browser’s own extension manager. chrome.runtime.openOptionsPage() respects whichever you declared, which means the same call produces two very different surfaces — and the embedded one is a small iframe with no address bar, where a deep link has to arrive some other way.

What openOptionsPage does, by declarationThe manifest key and open_in_tab flag decide whether the options page appears as a tab or as an embedded panel, which changes deep-linking and deduplication.Which options key does the manifest declare?options_ui, open_in_tab trueFull tabURL visible, hash routing worksDedupe with tabs.queryfocus rather than reopenoptions_ui, open_in_tab falseEmbedded paneliframe in the extensions managerPass state through storageno URL to deep-linkoptions_page (legacy)Full tabMV2 key, still honouredPrefer options_ui in MV3same result, current key
Choose the surface first — the code that opens it depends on the answer.

Step 1. Declare the surface you actually want

1{
2  "manifest_version": 3,
3  "options_ui": {
4    "page": "options.html",
5    "open_in_tab": true      // a real tab: addressable, bookmarkable, deep-linkable
6  },
7  "permissions": ["storage", "tabs"]   // tabs only if you dedupe by URL, see step 3
8}

Execution context: Read at install time. open_in_tab: true is the right default for anything larger than a handful of checkboxes: the embedded panel is roughly 600 pixels wide, cannot be resized by the user, and is rendered inside the browser’s own chrome, where your styling competes with the manager’s. Firefox honours both forms and additionally shows the embedded panel inside the add-ons manager’s detail view; Safari always opens a tab regardless of the flag, so a layout that only works at panel width will look sparse there.

Step 2. Open it from wherever the user asked

1// popup.js
2document.getElementById("settings").addEventListener("click", async () => {
3  await chrome.runtime.openOptionsPage();
4  window.close();                     // the popup has done its job
5});

Execution context: Popup document. chrome.runtime.openOptionsPage is available in the popup, the options page itself, the service worker and content scripts, and it needs no permission. Closing the popup afterwards matters: on Chrome the popup would otherwise stay open behind the new tab and be dismissed a moment later anyway, which reads as a flicker. Firefox behaves the same. On Safari the popup is dismissed automatically when focus moves, so the close() is harmless rather than required.

1// sw.js — the same call from a context menu item
2chrome.contextMenus.onClicked.addListener((info) => {
3  if (info.menuItemId === "open-settings") chrome.runtime.openOptionsPage();
4});

Execution context: Service worker, top-level listener. There is no user-gesture requirement on this API, so it also works from onInstalled — which is how a first-run setup page is shown, covered in showing a first-run setup page after install.

Step 3. Focus an existing tab instead of opening another

openOptionsPage deduplicates in Chrome — it focuses an existing options tab rather than creating a second. Firefox and Safari have historically not, so do it yourself when a duplicate would be confusing.

Focus-or-open, with the tabs APIThe worker queries for an existing options tab, activates it and its window if found, and only creates a new tab otherwise.CallerWorkertabs APIOptions tabOPEN_OPTIONS messagetabs.query({ url: optionsUrl })existing tab or emptytabs.update({ active: true })plus windows.update to raise itor tabs.create({ url })only when none exists
Two extra calls remove the most common complaint about extension settings.
 1// sw.js
 2const OPTIONS_URL = chrome.runtime.getURL("options.html");
 3
 4export async function focusOrOpenOptions(section) {
 5  const url = section ? `${OPTIONS_URL}#${section}` : OPTIONS_URL;
 6  const [existing] = await chrome.tabs.query({ url: `${OPTIONS_URL}*` });
 7
 8  if (existing) {
 9    await chrome.tabs.update(existing.id, { active: true, url });
10    await chrome.windows.update(existing.windowId, { focused: true });
11    return existing.id;
12  }
13
14  const created = await chrome.tabs.create({ url });
15  return created.id;
16}

Execution context: Service worker. chrome.runtime.getURL returns the extension-scheme URL of your own page and needs no permission; querying tabs by that URL does need the tabs permission, which is the cost of deduplication. Raising the window as well as activating the tab is what makes the behaviour feel right when the options page is in a background window — activating the tab alone leaves the user staring at the window they were already in. The wildcard in the query pattern is what lets it match a tab that already carries a hash.

A settings page with tabs benefits enormously from “take me to the section this message is about”. A hash is enough.

 1// options.js
 2function activateFromHash() {
 3  const requested = location.hash.slice(1);
 4  const panel = requested && document.getElementById(`panel-${requested}`);
 5  if (!panel) return activatePanel("general");           // unknown or empty hash
 6
 7  activatePanel(requested);
 8  panel.scrollIntoView({ block: "start", behavior: "instant" });
 9  document.getElementById(`tab-${requested}`)?.focus();  // keyboard users land in the right place
10}
11
12window.addEventListener("hashchange", activateFromHash);
13document.addEventListener("DOMContentLoaded", activateFromHash);

Execution context: Options page document, external script. Listening for hashchange as well as the initial load is what makes step 3 work when the tab is already open: updating an existing tab’s URL to a new hash fires hashchange rather than reloading, so without the listener the tab is focused but the wrong section stays visible. Moving focus to the section’s tab control keeps keyboard users oriented, following the pattern in making popups and options keyboard navigable.

When open_in_tab is false there is no address to link to. Pass the intent through storage and clear it on read.

Deep-linking by surfaceA tab-based options page can carry a hash; an embedded panel has no URL, so the section must be passed through session storage.MechanismTab (open_in_tab true)Embedded panelURL hashWorksNo URL to sethashchange listenerRequiredNever firesstorage.session intentWorksWorksBookmarkable by the userYesNoDedupe with tabs.queryYesNot applicable
The storage route works for both, which is why it is worth writing once.
 1// sw.js
 2export async function openOptionsAt(section) {
 3  await chrome.storage.session.set({ optionsIntent: { section, at: Date.now() } });
 4  await chrome.runtime.openOptionsPage();
 5}
 6
 7// options.js
 8const { optionsIntent } = await chrome.storage.session.get("optionsIntent");
 9if (optionsIntent && Date.now() - optionsIntent.at < 10_000) {
10  await chrome.storage.session.remove("optionsIntent");   // one-shot, never replayed
11  activatePanel(optionsIntent.section);
12}

Execution context: Service worker and options page. chrome.storage.session is the right area because the intent is meaningless after the browser closes and should never reach disk. Clearing it immediately on read is what stops the page jumping to a stale section the next time the user opens settings themselves; the ten-second window covers the case where the write succeeded but the page was never opened.

Cross-browser variation

  • Chrome/Edge: openOptionsPage focuses an existing options tab automatically. options_ui with open_in_tab: false renders inside chrome://extensions, where your page is in an iframe roughly 600 pixels wide.
  • Firefox: the embedded panel appears in the add-ons manager detail view and mirrors in right-to-left locales. Deduplication is not guaranteed, so the focus-or-open helper is worth using.
  • Safari: always opens a tab. The extension’s own settings live in Safari’s preferences, which is a separate surface you do not control — do not duplicate switches between the two.
  • All engines: the options page is an ordinary extension document under the extension content security policy: no inline handlers, no remote scripts.

Verification

  1. Click your settings entry point twice. Exactly one options tab should exist, focused.
  2. Move the options tab to a background window and click the entry point. The window should be raised.
  3. Open settings deep-linked to a section while the page is already open on another section. The visible section should change.
  4. Type a nonsense hash into the URL and reload. The page should fall back to the first section rather than showing nothing.
  5. Switch the manifest to open_in_tab: false and confirm the storage-based intent still lands on the right section.

FAQ

Does openOptionsPage need a user gesture?

No. It can be called from onInstalled, a message handler or an alarm, which is what makes a first-run page possible.

Can a content script open the options page?

Yes, chrome.runtime.openOptionsPage is available there. Prefer sending a message to the worker so all the deduplication logic lives in one place.

Should I use options_page or options_ui?

options_ui in Manifest V3. options_page still works but is the older key and offers no open_in_tab control.

Why does the hash not change the section on an already-open tab?

Because updating the hash does not reload the page. Listen for hashchange as well as DOMContentLoaded.

Other MV3 Architecture & Extension Lifecycle Resources