Moving and Grouping Tabs Programmatically

Reorder, move and group tabs from an MV3 service worker: index semantics across windows, the tabGroups permission, colour and title limits, and keeping group state consistent.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

chrome.tabs.move looks like an array splice and behaves like one only within a single window. Move several tabs at once and the indices shift under you; move across windows and the index means something different; group them and you discover that groups have their own permission, their own id space and their own lifetime. This page is part of Tabs API & Window Management and covers reordering and grouping that produce the arrangement you intended.

Root cause: indices are positions in a list that you are mutating

A tab index is its position within its window, and moving a tab renumbers every tab after it. Moving a set of tabs one call at a time therefore computes each subsequent index against a list that has already changed — which is why a tidy-up that looks correct in a two-tab test scrambles a twenty-tab window.

Why moving tabs one at a time goes wrongEach individual move renumbers the tabs after it, so indices captured before the first move are stale by the second.Capture indices[2, 5, 7]Move tab at 2everything after shiftsMove tab at 5no longer the tab you meantone call with an array of ids avoids the whole problemtabs.move([id, id, id])browser handles the orderingContiguous placementin the order given
Move the whole set in one call, or compute indices from the end — anything else is arithmetic against a moving target.

Step 1. Move a set in one call

chrome.tabs.move accepts an array of tab ids and places them contiguously starting at the given index, in the order supplied. That single call is both correct and atomic from your perspective.

 1// background.js — gather related tabs and move them together
 2export async function gatherByOrigin(origin) {
 3  const tabs = await chrome.tabs.query({ currentWindow: true });
 4  const matching = tabs
 5    .filter((tab) => { try { return new URL(tab.url).origin === origin; } catch { return false; } })
 6    .map((tab) => tab.id);
 7
 8  if (matching.length === 0) return [];
 9
10  // -1 means "the end of the window"; a positive index places them starting there.
11  const moved = await chrome.tabs.move(matching, { index: -1 });
12  return Array.isArray(moved) ? moved : [moved];
13}

Execution context: Extension service worker with the tabs permission or matching host access. move returns a single tab when given a single id and an array when given several, which is the kind of API shape that produces a moved.map is not a function in production — normalising it once at the boundary is worth the line. Pinned tabs cannot be moved after unpinned ones and vice versa; a move that would violate that ordering is silently clamped rather than rejected.

Step 2. Understand what index means across windows

Moving to another window takes a windowId alongside the index, and the index is a position in the destination window. A tab cannot be moved into a window of a different type — a normal tab cannot join a popup window — and attempting it rejects.

 1// background.js
 2export async function moveToWindow(tabIds, windowId, index = -1) {
 3  const target = await chrome.windows.get(windowId);
 4  if (target.type !== "normal") throw new Error("target-window-not-normal");
 5
 6  return chrome.tabs.move(tabIds, { windowId, index });
 7}
 8
 9export async function moveToNewWindow(tabIds) {
10  const [first, ...rest] = tabIds;
11  // Create the window with the first tab, then move the remainder into it.
12  const window = await chrome.windows.create({ tabId: first, focused: true });
13  if (rest.length) await chrome.tabs.move(rest, { windowId: window.id, index: -1 });
14  return window.id;
15}

Execution context: Extension service worker with the tabs permission. Creating a window with tabId moves that tab rather than opening a new page, which is the supported way to detach a tab — there is no tabs.detach. The two-step shape is necessary because windows.create accepts only one tabId, and doing the remainder in a single move keeps their relative order intact.

Step 3. Group tabs, and mind the separate permission

Tab groups need the tabGroups permission for reading and updating group metadata, though chrome.tabs.group itself works with tabs. Group ids are stable while the group exists and are reused after it is gone, so never persist one as a long-lived reference.

The lifetime of a tab group and its idA group is created with its first tabs, persists while it has members, is destroyed when the last tab leaves, and its id may later be reused.tabs.group()id reused elsewhereGroup cre…id assignedMembers come and gouser drags tabsLast tab …group dest…onRemoved…clean up s…Id reusedunrelated groupquery membership, never remember itstale state would attach here
The reuse at the end is why group ids must never be persisted as long-lived references.
 1// background.js
 2export async function groupByOrigin(origin, title) {
 3  const ids = await gatherByOrigin(origin);
 4  if (ids.length === 0) return null;
 5
 6  const groupId = await chrome.tabs.group({ tabIds: ids });
 7
 8  // update() needs the tabGroups permission. Colour is from a fixed enum, not a hex value.
 9  await chrome.tabGroups.update(groupId, {
10    title: title.slice(0, 40),        // long titles are truncated in the strip
11    color: "blue",                    // grey, blue, red, yellow, green, pink, purple, cyan, orange
12    collapsed: false,
13  });
14
15  return groupId;
16}

Execution context: Extension service worker. chrome.tabs.group returns the group id and will add tabs to an existing group if you pass groupId in the options. The colour enum is fixed — there is no custom colour — and passing an unrecognised value rejects rather than falling back, so validate against the enum if the value comes from user settings. A group is destroyed when its last tab leaves it, and its id may later be reused by an unrelated group, which is why group membership should be recomputed rather than remembered.

Tab and group operations and what each requiresPermissions, whether the operation is atomic for a set of tabs, and browser support for the common tab arrangement calls.OperationPermissionSet-atomic?Firefox / Safaritabs.movetabs or hostYes, with an arraySupportedwindows.create({ tabId })tabsOne tab onlySupportedtabs.grouptabsYesNot availabletabGroups.updatetabGroupsOne groupNot availabletabs.ungrouptabsYesNot available
The grouping rows are where portability breaks — Firefox and Safari have no equivalent API.

Step 4. Degrade where groups do not exist

Grouping is a Chromium feature. Firefox and Safari have no tab groups, so a feature built on them needs a fallback that delivers the same intent — usually gathering the tabs together and, optionally, moving them into their own window.

 1// background.js
 2export async function organiseByOrigin(origin, title) {
 3  const ids = await gatherByOrigin(origin);
 4  if (ids.length === 0) return { arranged: 0 };
 5
 6  if (chrome.tabs.group && chrome.tabGroups) {
 7    const groupId = await groupByOrigin(origin, title);
 8    return { arranged: ids.length, groupId };
 9  }
10
11  // No groups: the tabs are already contiguous from the move. Offer a window instead.
12  return { arranged: ids.length, groupId: null };
13}

Execution context: Extension service worker. Feature-detecting both chrome.tabs.group and chrome.tabGroups is deliberate — the first can exist without the permission for the second, and calling tabGroups.update without the permission throws at the call site rather than at load. Because the gather step already moved the tabs together, the non-grouping engines still get most of the value, which is the right shape for a progressive enhancement.

Step 5. Keep your own state out of the arrangement

Users move tabs, close them and regroup them by hand. Any record you keep of “the tabs in this group” is stale immediately, so derive it on demand instead.

1// background.js — always ask, never remember
2export async function tabsInGroup(groupId) {
3  return chrome.tabs.query({ groupId });
4}
5
6chrome.tabGroups.onRemoved.addListener((group) => {
7  // Clean up any per-group scratch state; the id may be reused by an unrelated group later.
8  void chrome.storage.session.remove(`group:${group.id}`);
9});

Execution context: Extension service worker with tabGroups. Querying by groupId is cheap and always correct, whereas a stored membership list is wrong the moment the user drags a tab. Handling onRemoved matters specifically because group ids are recycled: leftover state under a recycled id attaches itself to a group the user created for something else entirely.

Cross-browser variation

  • Chrome 120+: full support for tabs.move, tabs.group, tabs.ungroup and the tabGroups namespace with a fixed colour enum.
  • Firefox 121+: tabs.move is supported with the same semantics; there is no tab-group API. Gathering tabs together is the portable subset.
  • Safari 17+: tabs.move is supported; no tab groups. Window creation from a tab behaves the same way.
  • All engines: pinned tabs occupy the leading positions and cannot be interleaved with unpinned ones. Moves that would violate that are clamped, not rejected, so verify the resulting index rather than assuming it.

Verification

  1. Open twenty tabs from three origins and gather one origin. All matching tabs should end up contiguous and in their original relative order.
  2. Do the same with three separate move calls and observe the scrambling — that is the bug the array form avoids.
  3. Group the gathered tabs, then drag one out by hand and re-query by groupId. The query should reflect the user’s change immediately.
  4. Run the same flow in Firefox and confirm the tabs are gathered without an error, with grouping skipped.

FAQ

Why did my tab move to a different index than I asked for?

Most often the pinned boundary: an unpinned tab cannot be placed before pinned ones, so the index is clamped. Read the returned tab’s index rather than assuming your requested value.

Do I need the tabGroups permission to create a group?

Not to call chrome.tabs.group, but yes to read or update group metadata through chrome.tabGroups. Since a group with no title and no colour is rarely what you want, most extensions need both.

Are group ids stable?

Only while the group exists. They are recycled afterwards, so a stored group id can later refer to a completely unrelated group — derive membership by query instead.

Can I group tabs across windows?

No. A group belongs to one window, so move the tabs into the same window first, then group them.

Other Core APIs & Cross-Browser Data Management Resources