Context Switcher <mv-context-switcher>

Header switcher between a root context (a group, an organization, all stores) and one of its members, built on the menu: the trigger shows where you are, the list can arrive after the first render with a loading row, an optional button jumps back to the root, and a cancelable change event lets the app confirm or refuse the switch.

CategoryNavigation
TypeWeb Component (<mv-context-switcher>)
Statusstable
Also installspopover, menu
Keywordscontext switcher, scope switcher, organization switcher, company switcher, workspace switcher, team switcher, tenant, multi-company, group view, app header, back office, erp, menuitemradio, async list

When to use

  • A back office moves between a consolidated group view and one company, with a quick way back to the group
  • An app header must show which organization, workspace or store the whole screen is scoped to
  • The list of companies or workspaces comes from an API after the page has rendered
  • The app must confirm or refuse a context change, for example while a form has unsaved edits

Avoid when

  • The list holds hundreds of entries that people need to search by typing → use Combobox instead
  • The trigger opens actions such as Rename or Delete rather than a choice of scope → use Menu instead
  • The choice is a form value submitted with other fields → use Select instead

Install

node scripts/add.mjs context-switcher --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["context-switcher"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/position.js, core/svg.js, components/popover/popover.js, components/popover/popover.css, core/dismiss.js, components/menu/menu.js, components/menu/menu.css, components/context-switcher/context-switcher.js, components/context-switcher/context-switcher.css.

Usage

Quick start, the smallest working markup:

<mv-context-switcher root-label="Halden Group" back-button>
  <option value="halden-fr" data-description="Paris · EUR">Halden France</option>
  <option value="halden-uk" data-description="London · GBP">Halden UK</option>
</mv-context-switcher>

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<div id="cs-demo" style="display:grid;gap:1.5rem;width:100%;max-width:56rem;margin-inline:auto">
  <div style="border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);overflow:hidden;background:var(--mv-surface)">
    <header style="display:flex;align-items:center;gap:.75rem;min-height:3.5rem;padding:.375rem .75rem;border-bottom:1px solid var(--mv-border)">
      <mv-context-switcher id="cs-erp" root-label="Halden Group" back-button loading></mv-context-switcher>
      <span style="margin-inline-start:auto;font-size:.8125rem;color:var(--mv-fg-muted);white-space:nowrap">October 2026</span>
    </header>
    <div style="display:grid;gap:.25rem;padding:1.25rem 1.25rem 1.5rem">
      <span id="cs-erp-scope" style="font-size:.8125rem;color:var(--mv-fg-muted)">Consolidated, 4 companies</span>
      <strong id="cs-erp-revenue" style="font-size:1.75rem;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums">$9,642,300</strong>
      <span style="font-size:.8125rem;color:var(--mv-fg-muted)">Revenue this month, reported in USD</span>
    </div>
  </div>

  <div style="display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;padding:.5rem .75rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-lg)">
    <mv-context-switcher id="cs-stores" data-size="sm" data-variant="outline" root-label="All stores" list-label="Stores" value="lisbon" back-button>
      <option value="lisbon" data-description="Rua Augusta 112">Lisbon Baixa</option>
      <option value="porto" data-description="Rua de Santa Catarina 48">Porto Centro</option>
      <option value="madrid" data-description="Calle de Fuencarral 21">Madrid Malasaña</option>
    </mv-context-switcher>
    <span style="font-size:.8125rem;color:var(--mv-fg-muted)">Compact, outlined, list written as <code>&lt;option&gt;</code> children</span>
  </div>
</div>

<script type="module">
  const erp = document.getElementById("cs-erp");
  const stores = document.getElementById("cs-stores");
  stores.strings = { root: "All stores", item: "Store", rootHint: "Every location", none: "Select a store" };

  const companies = [
    { value: "halden-fr", label: "Halden France", description: "Paris · EUR", revenue: 3218400 },
    { value: "halden-de", label: "Halden Germany", description: "Munich · EUR", revenue: 2764900 },
    { value: "halden-uk", label: "Halden UK", description: "London · GBP", revenue: 2105000 },
    { value: "halden-us", label: "Halden US", description: "Chicago · USD", revenue: 1554000 },
    { value: "halden-jp", label: "Halden Japan", description: "Osaka · onboarding", disabled: true, revenue: 0 },
  ];
  const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
  const total = companies.reduce((sum, c) => sum + c.revenue, 0);

  // The list arrives after the first render, as from an API.
  setTimeout(() => {
    erp.items = companies.map(({ revenue, ...c }) => c);
    erp.loading = false;
  }, 1400);

  erp.addEventListener("mv-change", (e) => {
    const company = companies.find((c) => c.value === e.detail.value);
    document.getElementById("cs-erp-scope").textContent = e.detail.root ? "Consolidated, 4 companies" : `${e.detail.label}, ${e.detail.item?.description ?? ""}`;
    document.getElementById("cs-erp-revenue").textContent = usd.format(e.detail.root ? total : company?.revenue ?? 0);
  });
</script>

API

Attributes

NameTypeDefaultDescription
valuestringValue of the active member. Empty, absent or equal to root-value: the root context is active. Setting it does not emit mv-change.
root-labelstringName of the root context (e.g. the group). Without it there is no root entry and no back button: a plain member switcher.
root-valuestring""Value that stands for the root context in value and in mv-change.
list-labelstringHeading above the members (overrides strings.list).
loadingbooleanThe member list is being fetched: a busy row replaces it (aria-busy) and the trigger shows strings.loading if value is not known yet. Removing it while the menu is open announces the count.
back-buttonbooleanShows an icon button next to the trigger, while a member is active, that returns to the root context.
placementtop | bottom | left | right[-start|-end]bottom-startMenu position relative to the trigger (automatic flip/shift).
data-sizemd | smmdsm: one-line trigger for dense toolbars (the kicker stays for screen readers).
data-variantghost | outlineghostoutline: bordered trigger on a surface, for toolbars and filters.
data-contextroot | itemSet by the component on itself: which kind of context is active (for styling the page around it).

Properties

NameTypeDescription
itemsArray<{ value, label, description?, mark?, hue?, disabled? }> | nullMembers, for lists loaded by script. Replaces the <option> children; null goes back to them. Assignable before the element is defined.
strings{ root, item, list, rootHint, none, loading, empty, loaded, back }Default texts, for translation: kickers ("Group view", "Company"), list heading, root hint ("All companies"), placeholder, loading, empty, loaded announcement ("Companies loaded: {count}") and back button name ("Back to {label}"). Assignable before the element is defined.
current{ value, label, root }The active context (read-only).
isOpenbooleanWhether the menu is open (read-only).

Methods

NameDescription
show({ focus })Opens the menu; focus = "first" | "last" | "panel".
hide({ focus })Closes the menu; focus=true returns focus to the trigger.

Events

NameDescription
mv-changeThe user picked another context (menu or back button). Cancelable: preventDefault keeps the current one. detail: { value, label, root, item, previous } (item is the member object, null for the root).
mv-open / mv-closeFrom the inner menu, when it opens and closes.

Content structure

NameDescription
<option>One member each: value, label (or text), data-description, data-mark (badge text, initials by default), data-hue (badge hue 0-360, derived from the value by default), disabled. Never rendered; added, removed or changed later, they update the list.

CSS classes

NameDescription
mv-context-switcher-trigger / -mark / -kicker / -label / -chevronGenerated trigger parts (mark and chevron are aria-hidden; the root mark carries data-root).
mv-context-switcher-backGenerated back button (hidden while the root is active).
mv-context-switcher-panel / -option / -name / -description / -statusGenerated menu panel, member rows (role=menuitemradio) and the loading or empty row.

CSS variables

NameDefaultDescription
--mv-context-switcher-max-width18remTrigger maximum width; longer names are truncated with an ellipsis.
--mv-context-switcher-menu-width16remMenu minimum width.
--mv-context-switcher-mark-size1.75remTrigger badge size (1.25rem in data-size="sm").

Accessibility

APG Menu Button: the trigger is a native button with aria-haspopup=menu and aria-expanded, named by its visible text (kicker then the active context, e.g. “Company Halden UK”). The panel is a role=menu with two labelled groups (the root, then the members) of menuitemradio rows whose aria-checked follows the active context. Keyboard from the menu: Enter, Space or ↓ open on the first row, ↑ on the last; ↑ ↓ Home End move (wrapping), letters jump by name (accent-insensitive), Enter or Space switch, Escape closes and Tab closes and moves on, focus returns to the trigger, which then reads the new context. Disabled members stay visible with aria-disabled and are skipped. While loading, the list has aria-busy and a polite status announces “Loading companies…” on open and the count once the list arrives; if rows are replaced while focus is in them, focus moves to the checked or first row instead of being lost. The back button is a real button named “Back to Halden Group”; it is removed from the tab order while the root is active and focus moves to the trigger after it is used. Badges are decorative (aria-hidden), colors are never the only cue, forced colors use system colors. Known limits: no search field inside the menu (use combobox for very long lists), and the menu grows with the list up to the viewport height, then scrolls.