Components / ScopeSwitcher

ScopeSwitcher

Picking WHICH thing a surface acts on — which project the rail is in, which organization a billing dialog is reading. It is a scope control, not a form field: for choosing a VALUE inside a form, use Select (one of many) or RadioGroup (one of a few).

import { ScopeSwitcher } from '@layers/ui/scope-switcher'

Rendered stock

House rules

  • A popover, never a dialog. Switching scope is navigation, not a task, and a modal with a backdrop says "stop and finish this". The project switcher was a centred modal until 2026-08-01; 87% of accounts hold exactly one project, so it was a backdrop and 700px of height to choose between one thing.
  • PopoverContent is the ONE surface. It takes `p-0` and CommandInline takes `border-0`, because each otherwise draws its own container and the search input draws a third — the modal this replaced stacked all three and content never touched an edge. Both overrides only REMOVE a surface; neither invents styling. Same shape as shadcn’s own combobox upstream.
  • Search appears only at `searchThreshold` (8) options or more. Below that it is a field that filters one row, which is a control that does nothing — the dead affordance rules.md forbids.
  • The create action is pinned OUTSIDE CommandList, which is the scroller, so the list moves between two fixed edges. It is also the one row that is not a search result: filtering it away would hide it at the exact moment someone has typed a name that does not exist yet.
  • Group headings stick while their rows scroll under. This needs `overflow-visible` on the group — CommandGroup ships `overflow-hidden`, which makes each group its own scroll container, so a sticky heading pins to a box that scrolls away with it and looks precisely like sticky being broken.
  • An option may carry `backdrop` — the colour ScopeAvatar paints behind its mark, resolved server-side from the SAME media-library row as `iconUrl` so a mark and its ground can never be mismatched across projects. Resolving it per-option on the client is not an option: useMediaLibrary is per-project, and a twenty-project switcher would mean twenty requests to draw one popover.
  • Item values are `name|id` and the filter reads only the name half, so two things sharing a name stay separately selectable. Names are not unique and the switcher must not pretend they are.
  • Open state is INTERNAL and closes on select. Base UI closes a popover on outside-click and Escape, and a click inside is neither — an uncontrolled one sits open over the thing you just switched to. `onOpenChange` reports transitions out for callers that need them; the rail uses it to stay expanded, since it collapses on pointer-leave and would otherwise shut behind the popover.
  • Rows draw a ScopeAvatar. A switcher whose job is recognising something must render what that thing looks like; a column of left-aligned strings is the failure mode this replaced.