Navigation

Workspace Switcher

A workspace / organization switcher — active workspace in the trigger, grouped searchable menu in a portalled panel, pinned create + settings footer.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Check, ChevronsUpDown, Plus, Search, Settings } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance keyframes ship with the component: React 19 hoists <style href> into
 * <head> and dedupes by href, so N switchers on a page still emit one rule set.
 * One keyframe per side — the panel always slides *away* from its trigger.
 */
const KEYFRAMES = `@keyframes zg-workspace-in-bottom{from{opacity:0;transform:translateY(-4px) scale(0.98)}to{opacity:1;transform:none}}
@keyframes zg-workspace-in-top{from{opacity:0;transform:translateY(4px) scale(0.98)}to{opacity:1;transform:none}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/workspace-switcher.json

Prompt

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 no overflow: hidden sidebar or card can clip it. The flip side is that such ancestors must not be used as a boundary either; only overflow: auto | scroll ancestors (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's scrollTop. 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 searchThreshold workspaces 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-activedescendant highlight — focus never leaves the search box, so every printable key types; the arrows move a highlight that lives in aria-activedescendant on 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-activedescendant points 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 saved overflow / paddingRight are data attributes on body, 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 the overflow: hidden write, not guessed with innerWidth - clientWidth, which over-pads under scrollbar-gutter: stable and shifts the page left instead of holding it still.

On This Page