---
title: Scope Switcher
description: A searchable switcher for the team, server or site a page is about.
visibility: guest
draft: false
related:
  - /components/popover
  - /components/breadcrumb
  - /components/combobox
  - /components/command-palette
---

# Scope Switcher

The control in a breadcrumb that changes **which thing the page is about** — this team, this
server, this site. It shows the current one, and opening it offers the others with a search
field above them and a way to create a new one below.

It is navigation, not a form field. Choosing an entry goes to a page; nothing is submitted,
and there is no hidden input. That distinction is what decides its whole shape, and it is
why the [combobox](./combobox.md) and the [dropdown](./dropdown.md) are the wrong starting
points — see [Why not a combobox or a dropdown](#why-not-a-combobox-or-a-dropdown).

## Usage

:::preview{title="A server switcher"}
<x-wirekit::scope-switcher
    name="servers"
    label="Server"
    current="web-02"
    :items="[
        ['key' => 'web-01', 'label' => 'web-01.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'web-02', 'label' => 'web-02.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'web-03', 'label' => 'web-03.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'api-01', 'label' => 'api-01.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'api-02', 'label' => 'api-02.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'worker-01', 'label' => 'worker-01.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'worker-02', 'label' => 'worker-02.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'cache-01', 'label' => 'cache-01.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'db-01', 'label' => 'db-01.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'db-02', 'label' => 'db-02.example.com', 'url' => '#', 'icon' => 'server'],
        ['key' => 'staging', 'label' => 'staging.example.com', 'url' => '#', 'icon' => 'server'],
    ]"
    :create="['label' => 'New server', 'url' => '#', 'icon' => 'plus']"
/>
:::

The `name` is required and must be stable. Every option's DOM id is built from it, and the
search field points at those ids through `aria-activedescendant` — a name that changes
between renders quietly severs the link between the field and the row it says is active.

## Item shape

Only `key`, `label` and `url` are required.

| Key | Type | Meaning |
| --- | --- | --- |
| `key` | `string` | Stable and unique. Becomes part of the option's DOM id |
| `label` | `string` | What the reader sees and searches |
| `url` | `string` | Where choosing it goes |
| `icon` | `string` | An icon alias |
| `image` | `string` | A URL — an avatar or favicon. Wins over `icon` |
| `status` | `string` | `success`, `warning`, `danger` or `neutral` — a dot on the icon |
| `meta` | `string` | Secondary text, aligned to the trailing edge |
| `keywords` | `array` | Extra words the search should match, never displayed |
| `group` | `string` | A heading to file this entry under |

A duplicate `key`, an unknown `status` or a missing `name` throws rather than rendering
something subtly wrong.

## Status, metadata and groups

:::preview{title="Grouped, with status and metadata"}
<x-wirekit::scope-switcher
    name="sites"
    label="Site"
    current="www"
    :items="[
        ['key' => 'www', 'label' => 'www.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'success', 'meta' => 'PHP 8.4', 'group' => 'Production'],
        ['key' => 'shop', 'label' => 'shop.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'warning', 'meta' => 'PHP 8.3', 'group' => 'Production'],
        ['key' => 'staging', 'label' => 'staging.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'neutral', 'meta' => 'PHP 8.4', 'group' => 'Staging'],
        ['key' => 'preview', 'label' => 'preview.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'neutral', 'meta' => 'PHP 8.4', 'group' => 'Staging'],
    ]"
    :create="['label' => 'New site', 'url' => '#']"
/>
:::

A group whose entries are all filtered out hides its heading with them — a heading over
nothing reads as a bug.

A `status` renders as a dot on the entry's icon, and a dot is not readable — so each one also
puts its meaning into the row as text only a screen reader sees. The four words are
translatable like every other string this library emits:

| `status` | Announced |
| --- | --- |
| `success` | Healthy |
| `warning` | Needs attention |
| `danger` | Failing |
| `neutral` | Inactive |

Pass anything else and the entry renders without a dot, with the reason in the log when
`APP_DEBUG` is on — a status nobody can see is worse than none.

## In a breadcrumb

Each level is its own switcher, and the entries of one usually depend on the choice in the
one before it.

:::preview{title="Two levels, each switchable"}
<x-wirekit::row gap="xs" style="align-items: center;">
    <x-wirekit::scope-switcher
        name="teams"
        label="Team"
        current="dev"
        :items="[
            ['key' => 'dev', 'label' => 'dev', 'url' => '#', 'icon' => 'users'],
            ['key' => 'ops', 'label' => 'ops', 'url' => '#', 'icon' => 'users'],
        ]"
    />
    <x-wirekit::text variant="muted" aria-hidden="true">/</x-wirekit::text>
    <x-wirekit::scope-switcher
        name="breadcrumb-sites"
        label="Site"
        current="www"
        :items="[
            ['key' => 'www', 'label' => 'www.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'danger'],
            ['key' => 'shop', 'label' => 'shop.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'success'],
            ['key' => 'staging', 'label' => 'staging.example.com', 'url' => '#', 'icon' => 'globe', 'status' => 'success'],
        ]"
        :create="['label' => 'New site', 'url' => '#']"
    />
</x-wirekit::row>
:::

## Searching

Typing filters the list and never reorders it — the order the server sent is the order the
reader sees, before and after. Every word typed must match, so adding one narrows the result
rather than widening it.

It tolerates a typo. Each word is tried as a substring first, then as a subsequence — which
forgives a dropped letter, so `wrker` still finds `worker-01` — and finally against each part
of the entry within one edit, or two once the word is long enough to afford it. A swapped
pair of letters counts as one edit rather than two, because that is the commonest way a
hostname typed from memory comes out wrong.

Accents are folded on both sides, so `munchen` finds `München`. `keywords` join the
searchable text without appearing in the row, which is where an internal name or an IP
address belongs.

The filtering happens in the browser, which suits the few hundred entries a breadcrumb level
normally has. Past `client_filter_max` the component notes in the debug log that it is
shipping the whole list to search a fraction of it.

## Why not a combobox or a dropdown

Both are close, and both are wrong for a reason worth knowing.

A [combobox](./combobox.md) is a **form field**. Its trigger is the text input, and its value
travels in a hidden input to be submitted. Here the value is the page you are on.

A [dropdown](./dropdown.md) implements the WAI-ARIA **menu** pattern. A text input inside
`role="menu"` breaks the owned-elements rule — a menu may contain menu items, and an input is
not one — so a menu cannot hold a search field. It offers type-ahead instead, which jumps but
does not filter.

So this composes the two halves directly: a [popover](./popover.md) for the anchored panel
and focus handling, holding a `role="listbox"` of links with a search field above them.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | — | **Required.** Stable id base |
| `label` | `string` | `''` | What is being switched. Names the control and the dialog |
| `items` | `array` | `[]` | The entries — see the shape above |
| `current` | `string\|null` | `null` | `key` of the entry the page is showing |
| `create` | `array\|null` | `null` | `['label' => …, 'url' => …, 'icon' => …]` |
| `searchPlaceholder` | `string\|null` | derived | Placeholder and accessible name of the field |
| `emptyText` | `string\|null` | `No results.` | Shown when nothing matches |
| `placement` | `string\|null` | `bottom-start` | Where the panel opens |
| `width` | `string\|null` | `20rem` | Panel width |
| `listMaxHeight` | `string\|null` | `22rem` | Height at which the list starts scrolling |

Defaults come from `config/wirekit.php` under `components.scope-switcher`.

## Accessibility

- The trigger takes `aria-haspopup="dialog"`, `aria-expanded` and `aria-controls` from the
  popover, and is named after what it switches.
- The panel is a `role="dialog"` named "Switch <label>" rather than the generic word.
- The search field is a `role="combobox"` that controls the listbox and publishes the active
  row with `aria-activedescendant`.
- **`aria-selected="true"` marks the current scope only** — not the keyboard's position.
  Those are two different states: one is where the page is, the other is where the reader is
  looking. The keyboard's position uses `data-active` for its highlight.
- A live region announces the number of matches, throttled so a fast typist is not read out
  letter by letter.
- The create action sits **outside** the listbox. It is an action, not one of the things
  being chosen between, so it is reached with `Tab` while the arrow keys stay in the list.

## Keyboard Interaction

Focus stays in the search field the whole time. The rows are never focused; the active one is
published through `aria-activedescendant`, which is what lets you type and steer at once.

| Key | Action |
| --- | --- |
| `Enter` / `Space` on the trigger | Open, focus the search field, start on the current entry |
| Any text | Filter; the first match becomes active |
| `ArrowDown` / `ArrowUp` | Move between visible entries. No wrap |
| `Home` / `End` | First / last visible entry |
| `Enter` | Go to the active entry. On the current one, just close |
| `Tab` | Move to the create action, if there is one |
| `Escape` | Close and return focus to the trigger |

Choosing the entry you are already on closes without navigating. A reader who opens the
switcher and picks the current scope has changed their mind, and a page load to arrive where
they already are says nothing.

## Design Tokens

| Token | Used for |
| --- | --- |
| `--color-wk-bg-subtle` | Row hover |
| `--color-wk-bg-muted` | The keyboard's active row |
| `--color-wk-accent` | The check on the current entry |
| `--color-wk-border` | Header and footer separators |
| `--color-wk-text-muted` | Placeholder, metadata, group headings |

## Config Defaults

Every prop below falls back to `config/wirekit.php`, so an application sets the shape once
rather than on each switcher:

```php
// config/wirekit.php
return [
    'components' => [
        'scope-switcher' => [
            // 1. Where the panel opens relative to the trigger.
            'placement' => 'bottom-start',
            // 2. Distance in px between trigger and panel.
            'offset' => 8,
            // 3. Panel width. It never exceeds the viewport — the panel caps itself at
            //    calc(100vw - 2rem), so this is a preference and not a risk on a phone.
            'width' => '20rem',
            // 4. How tall the list grows before it scrolls.
            'list_max_height' => '22rem',
            // 5. Warm the target page on hover, so choosing feels instant.
            'prefetch_on_hover' => true,
            // 6. Above this many entries, the debug log notes that the whole list is being
            //    shipped to the browser to search a fraction of it. Nothing breaks; it is a
            //    nudge toward a server-side search at a size a breadcrumb rarely reaches.
            'client_filter_max' => 300,
        ],
    ],
];
```

## See Also

- [Popover](./popover.md) — the panel this is built on
- [Breadcrumb](./breadcrumb.md) — where a scope switcher usually lives
- [Command Palette](./command-palette.md) — for searching everything at once, not one level
