Workspace Switcher
A workspace / organization switcher — active workspace in the trigger, grouped searchable menu in a portalled panel, pinned create + settings footer.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/workspace-switcher.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "WorkspaceSwitcher" component (lucide-react
for the glyphs; no popover / floating-ui dependency — the panel is hand-placed).
Contract
- export const WorkspaceSwitcher = forwardRef<HTMLButtonElement,
WorkspaceSwitcherProps>. The ref, className and every remaining native button
prop land on the trigger <button>, which is the component's only in-flow node
(the panel is portalled). Also export the Workspace / WorkspaceGroup types.
- Workspace: { id: string (unique across every group — it is the value handed
back); name: string; logoUrl?: string; plan?: string (short pill: "Pro",
"Hobby", "Enterprise"); keywords?: string[] (searched, never rendered);
disabled?: boolean (dimmed, skipped by the arrows, not selectable — no seat,
suspended billing) }.
- WorkspaceGroup: { label: string; workspaces: Workspace[] } — sections render in
the given order ("Personal", "Teams", "Agencies").
- WorkspaceSwitcherProps: groups: WorkspaceGroup[]; value: string;
onValueChange: (id: string) => void; open?: boolean; onOpenChange?: (open:
boolean) => void; align?: "start" | "end" (default "start", a preference — it
still shifts); searchThreshold?: number (default 5); searchPlaceholder?;
emptyText?; placeholder? (shown when `value` matches nothing);
onCreate?/createLabel?; onSettings?/settingsLabel?; className? (trigger);
panelClassName? (portalled panel).
- Selection is fully controlled: the component stores no workspace of its own,
it only reports `onValueChange`. Open state is uncontrolled unless `open` is
passed; `onOpenChange` fires in both modes. A footer callback that is not
passed removes its row — never render a dead action.
Behavior
- Trigger: active workspace avatar (logo, or its initials — first letter for one
word, first + last initial for several) + name + optional plan pill +
ChevronsUpDown. The name is the only flexible child (`min-w-0` + `truncate`),
so a narrow sidebar ellipsises the name instead of pushing the chevron out.
Unknown `value` degrades to a dashed "+" avatar and the placeholder text.
- Opening: click toggles, ArrowUp/ArrowDown open. Closing: Escape, selecting a
workspace, a footer action, outside pointerdown, or a second trigger click.
Everything except the outside pointerdown restores focus to the trigger — an
outside press means the user is already somewhere else.
- The search box only appears once the total workspace count is *greater than*
searchThreshold (clamp the prop to >= 0 so 0 means "always show" rather than
"never"). Match case-insensitively against name + plan + keywords; groups that
filter down to nothing disappear; nothing matching at all renders an
emptyText paragraph with role="status".
- Focus model, and this is the part that decides whether it feels right: focus
goes into the search box and stays there, so every printable key types. The
arrows move a highlight expressed as aria-activedescendant on the input (never
DOM focus, and deliberately no typeahead — that would fight the search box).
When there is no search box, the role="menu" element itself takes tabIndex=-1
and carries aria-activedescendant so the same model still holds. Home/End jump
to the ends only in that no-search-box case; inside the input they belong to
the caret. Enter selects the highlighted row.
- Derive the highlighted row during render instead of storing an index: keep the
highlighted *id* in state and fall back to the first enabled row when the
current filter no longer contains it. Storing an index means a setState in an
effect and one frame where aria-activedescendant points at a removed node.
Opening resets the query and highlights the row matching `value`; do that with
render-phase adjust-state (compare a `prevOpen` state), never in an effect.
- Hover highlights (pointer and keyboard must never disagree), and rows
preventDefault on mousedown so the caret stays in the search box.
- Keep the highlighted row visible by writing the list's scrollTop directly
(offsetTop / clientHeight arithmetic against a `position: relative` scroller),
not scrollIntoView — that also scrolls ancestors and would move the page
behind the panel.
- Footer actions live *outside* the scrolling list, so "Create workspace" is
still reachable after typing a query that matches nothing.
- Placement — the whole reason this is not one absolutely-positioned div:
render the panel into a portal on <body> with `position: fixed`. Sidebars,
cards and docs preview stages set overflow:hidden constantly, and an in-flow
panel gets clipped: rows exist in the DOM, pixels don't, nothing is clickable.
Then clamp against `viewport ∩ every ancestor whose computed overflow is
auto|scroll`. Do NOT treat overflow:hidden ancestors as a boundary: the portal
already escaped them, and clamping into a 200px decorative card would crush
the panel instead of freeing it.
- One measurement pass: read the natural size with your own max-width /
max-height momentarily set to "none" (a panel already capped by the previous
pass always looks like it fits, which is how hand-rolled popovers end up
oscillating), restore the caps *and* the list's scrollTop, then flip to the top
only when the panel does not fit below and above is roomier, cap maxHeight to
the space that actually exists, align to the trigger edge, and shift back
inside the boundary.
- Run that pass inside a ResizeObserver callback on the panel and the trigger:
observe() fires once immediately, after layout and before paint, so it doubles
as the initial measurement and nothing setStates synchronously in an effect
body. Also re-run on window resize and on capture-phase scroll (passive, rAF
throttled, skipping scrolls that originate inside the panel), and re-arm the
observer when filtering changes the row count — a natural height that changes
under an unchanged clamped box never fires the observer. Guard the setState
with a layout-equality check or the observer loops on your own caps.
- Until the first measurement lands the panel has no coordinates: hide that
frame with opacity-0, never visibility:hidden — the latter silently drops the
focus() that follows.
- Scroll lock while open keeps BOTH the reentrancy count and the pre-lock
snapshot on `document.body` as data attributes —
`body.dataset.zyScrollLocks` (count), `.zyScrollLockOverflow`,
`.zyScrollLockPadding`. Snapshot body's current inline `overflow` /
`paddingRight` and freeze on the 0 → 1 edge, only increment afterwards, and
restore + delete all three attributes only on the 1 → 0 edge. Do NOT put any
of this in module-level variables: every component here is installed as its
own copy, so a switcher sitting inside a drawer (or beside a modal popover)
means several independent copies of the same lock on one page, each with a
private counter that cannot see the others. Nest two and the outer one
restores "" on close while the inner one later writes back the "hidden" it
recorded as the original — the page is unscrollable until a reload, with no
overlay left on screen to explain it. A body attribute is the one namespace
independent copies already share; keep the three names byte-identical
wherever this code is pasted.
- Scrollbar compensation is MEASURED, not predicted: read
`document.documentElement.clientWidth`, set `overflow: hidden`, read it
again, and add the positive difference to body's computed `paddingRight`.
The `innerWidth - clientWidth` shortcut is wrong on any page with
`scrollbar-gutter: stable` — the gutter is permanent, no width is reclaimed,
and padding ~15px anyway shifts the sidebar and the page LEFT the moment the
panel opens. The measurement yields 0 under macOS overlay scrollbars, so a
Mac-only test proves nothing about this branch.
- Escape is handled on the panel's own onKeyDown with stopPropagation (never a
window listener), so a switcher opened inside another overlay closes only
itself; a window listener has no notion of "topmost" and would close both
layers on a single press.
- Tab is trapped inside the panel (search box ↔ footer actions) because a
portalled panel's next tab stop is an unrelated corner of the document.
Before restoring focus anywhere, check `node.isConnected` — switching
workspace routinely re-renders or unmounts the trigger and focus would land on
<body>.
- Cleanup: the ResizeObserver, the rAF handle, the scroll/resize listeners, the
outside-pointerdown listener and the scroll-lock release all die with the open
state.
Rendering & styling
- Semantic tokens only: bg-background trigger with `border`, bg-popover /
text-popover-foreground panel with shadow-md, bg-accent /
text-accent-foreground for the highlighted row, bg-primary/10 + text-primary
avatar placeholder, text-muted-foreground for group labels, plan pills, the
chevron and the footer icons, text-primary for the check mark, `border`/
`border-t`/`border-b` for the dividers. No hex, no rgb(), no palette classes.
- ARIA: the trigger gets aria-haspopup="dialog" + aria-expanded +
aria-controls; the panel is role="dialog" aria-modal="true" with an
aria-label; the row list is role="menu"; each section is role="group" +
aria-labelledby pointing at its heading (heading itself role="presentation");
rows are role="menuitemradio" + aria-checked + tabIndex=-1, disabled ones use
aria-disabled (not the native attribute, so they stay announced). Never put a
roleless div between menu → group → menuitemradio: screen readers then
announce a menu that owns nothing.
- Row ids are positional (`${baseId}-row-${n}`), not derived from
`workspace.id` — a workspace id containing a space would break the
aria-activedescendant IDREF.
- Entrance keyframes (one per side) ship inline via a React 19 hoisted
<style href precedence="medium"> tag, deduped across instances, and every
animation/transition is wrapped in motion-reduce.
- SSR: gate createPortal behind useSyncExternalStore(subscribe, () => true, () =>
false) so the server and the hydrating frame agree.
Customization levers
- Density: the row's `px-2 py-1.5` plus the panel's `p-1` and the search box's
`h-10` are the entire density story — shrink them together for a compact
toolbar switcher, grow them for touch.
- Panel width: `w-72` on the panel, overridable through `panelClassName` (e.g.
`w-[22rem]` for long org names, or `w-full` combined with a wrapper). The
boundary clamp always wins over whatever you ask for.
- searchThreshold decides "menu" vs "picker": 0 always shows the search box,
a large number never does. GAP (6) and EDGE_MARGIN (8) tune the gap to the
trigger and the breathing room at the boundary; MIN_PANEL_HEIGHT (176) is the
floor the panel refuses to shrink below before it scrolls instead.
- Row anatomy: avatar / name / plan pill / check are four slots in one flex row.
Dropping the plan pill, or adding a member count or a region tag, is one more
shrink-0 span — the focus, filter and ARIA logic reads ids and DOM, not the
row's contents.
- Matching: `matchesQuery` is a single substring test over name + plan +
keywords. Swap it for fuzzy/subsequence matching there and nothing else
changes; `keywords` is the intended place for slugs and aliases.
- Footer: pass one, both or neither of onCreate / onSettings. A third action is
one more button with the same class — keep it outside the scroll area.
- Trigger skin: `className` is merged onto the button, so a ghost/borderless
sidebar header is `className="border-0 bg-transparent"` and a fixed-width
top-bar chip is `className="w-56"`.Concepts
- Current context in the trigger — the control is also a status readout: avatar + name + plan pill say which workspace every other route on the page belongs to, which is why the trigger is a real full-width button and not an icon.
- Portalled fixed panel — the panel renders into
document.body, so nooverflow: hiddensidebar or card can clip it. The flip side is that such ancestors must not be used as a boundary either; onlyoverflow: auto | scrollancestors (a scrollable sidebar, a dialog body) clamp the panel, intersected with the viewport. - Natural-size measurement — the flip and the height cap are computed from the panel's size with its own
max-*momentarily lifted, then restored together with the list'sscrollTop. Measuring an already-capped panel makes it look like it always fits, which is the usual source of popover jitter. - Search above a threshold — under
searchThresholdworkspaces the panel is a plain menu; above it a search box appears, and typing filters name + plan +keywords. The threshold is what keeps a two-team account from getting an empty-looking search field. aria-activedescendanthighlight — focus never leaves the search box, so every printable key types; the arrows move a highlight that lives inaria-activedescendanton the input. There is deliberately no typeahead, since it would compete with the search box for the same keystrokes.- Derived highlight — the highlighted workspace id is stored, but the highlighted row is derived during render, falling back to the first enabled row when the filter drops it. That removes the setState-in-effect and the frame where
aria-activedescendantpoints at a node that no longer exists. - Pinned footer — "Create workspace" and "Workspace settings" sit outside the scrolling list, so they stay put while the list scrolls and are still reachable when a query matches nothing.
- Scroll lock counted on
document.body— the count and the savedoverflow/paddingRightare data attributes onbody, not module-level variables. Each component here is installed as a separate copy, so the drawer this switcher sits in runs a different copy of the same lock with its own private counter; two blind counters restore each other's values (""first, then the"hidden"the second thought was original) and the page never scrolls again. A DOM attribute is the one namespace independent copies already share. The padding it restores is measured across theoverflow: hiddenwrite, not guessed withinnerWidth - clientWidth, which over-pads underscrollbar-gutter: stableand shifts the page left instead of holding it still.
Dropdown Menu
A generic dropdown menu — any trigger, structured rows (commands, checkboxes, radio groups, submenus), typeahead and clipping-aware placement.
App Launcher
A nine-dot app switcher — a grid button that drops a portalled tile grid, with real links, grid keyboard navigation, groups, pinned and recent rows, and a search box once the suite outgrows a 3×3.