Skip to content

Product switcher

A workspace switcher in the leading position, because it changes the meaning of everything after it.

Navbarsintermediateworkspacetenantswitchersaasmulti-product

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import Link from 'next/link'
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Dropdown } from '@/components/ui/dropdown'
import { productLinks } from '@/content/nav-demo'

/**
 * Product / workspace switcher
 *
 * The navigation shape used when one account spans several products or
 * workspaces. The switcher is the leftmost control because it changes the
 * meaning of everything to its right — placing it anywhere else invites people
 * to act in the wrong workspace.
 */
const workspaces = [
  { id: 'acme', name: 'Acme Platform', plan: 'Business' },
  { id: 'northwind', name: 'Northwind', plan: 'Team' },
  { id: 'personal', name: 'Personal', plan: 'Solo' },
]

export default function ProductSwitcherNavigation() {
  const current = workspaces[0]

  return (
    <div className="w-full border-b border-line bg-surface">
      <div className="mx-auto flex h-16 w-full max-w-7xl flex-wrap items-center gap-3 px-4 sm:px-6">
        <Link href="/" className="flex items-center gap-2 text-ink-strong">
          <BrandMark className="size-5 text-accent" />
          <span className="sr-only">Foundry home</span>
        </Link>

        <span className="text-line-strong" aria-hidden="true">
          /
        </span>

        <Dropdown
          label="Switch workspace"
          groups={[
            {
              label: 'Workspaces',
              items: workspaces.map((workspace) => ({
                id: workspace.id,
                label: workspace.name,
                icon: <Avatar name={workspace.name} size="xs" shape="square" decorative />,
                meta: workspace.plan,
                onSelect: () => {},
              })),
            },
            {
              items: [
                {
                  id: 'new',
                  label: 'Create workspace',
                  icon: <Plus className="size-4" />,
                  onSelect: () => {},
                },
              ],
            },
          ]}
          trigger={(props) => (
            <button
              type="button"
              className="flex h-9 items-center gap-2 rounded-md border border-line px-2.5 text-sm font-medium text-ink transition-colors hover:bg-surface-sunken"
              {...props}
            >
              <Avatar name={current?.name ?? 'Workspace'} size="xs" shape="square" decorative />
              <span className="max-w-32 truncate">{current?.name}</span>
              <Badge size="sm" tone="accent">
                {current?.plan}
              </Badge>
              <ChevronsUpDown className="size-3.5 text-ink-subtle" aria-hidden="true" />
            </button>
          )}
        />

        <nav aria-label="Workspace" className="hidden md:block">
          <ul className="flex items-center gap-1">
            {productLinks.slice(0, 3).map((link, index) => (
              <li key={link.href}>
                <Link
                  href={link.href}
                  aria-current={index === 0 ? 'page' : undefined}
                  className={
                    index === 0
                      ? 'flex h-8 items-center gap-1.5 rounded-md bg-surface-sunken px-3 text-sm font-medium text-ink-strong'
                      : 'flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink'
                  }
                >
                  {index === 0 ? (
                    <Check className="size-3.5 text-accent" aria-hidden="true" />
                  ) : null}
                  {link.label}
                </Link>
              </li>
            ))}
          </ul>
        </nav>

        <div className="ml-auto">
          <Avatar name="Priya Raman" size="sm" />
        </div>
      </div>

      <nav aria-label="Workspace, compact" className="border-t border-line-subtle px-4 md:hidden">
        <ul className="hide-scrollbar flex gap-1 overflow-x-auto py-2">
          {productLinks.map((link, index) => (
            <li key={link.href}>
              <Link
                href={link.href}
                aria-current={index === 0 ? 'page' : undefined}
                className={
                  index === 0
                    ? 'flex h-8 items-center rounded-md bg-surface-sunken px-3 text-sm font-medium whitespace-nowrap text-ink-strong'
                    : 'flex h-8 items-center rounded-md px-3 text-sm whitespace-nowrap text-ink-muted'
                }
              >
                {link.label}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
    </div>
  )
}

components/blocks/navigation/product-switcher.tsx

'use client'

import Link from 'next/link'
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Dropdown } from '@/components/ui/dropdown'
import { productLinks } from '@/content/nav-demo'

/**
 * Product / workspace switcher
 *
 * The navigation shape used when one account spans several products or
 * workspaces. The switcher is the leftmost control because it changes the
 * meaning of everything to its right — placing it anywhere else invites people
 * to act in the wrong workspace.
 */
const workspaces = [
  { id: 'acme', name: 'Acme Platform', plan: 'Business' },
  { id: 'northwind', name: 'Northwind', plan: 'Team' },
  { id: 'personal', name: 'Personal', plan: 'Solo' },
]

export default function ProductSwitcherNavigation() {
  const current = workspaces[0]

  return (
    <div className="w-full border-b border-line bg-surface">
      <div className="mx-auto flex h-16 w-full max-w-7xl flex-wrap items-center gap-3 px-4 sm:px-6">
        <Link href="/" className="flex items-center gap-2 text-ink-strong">
          <BrandMark className="size-5 text-accent" />
          <span className="sr-only">Foundry home</span>
        </Link>

        <span className="text-line-strong" aria-hidden="true">
          /
        </span>

        <Dropdown
          label="Switch workspace"
          groups={[
            {
              label: 'Workspaces',
              items: workspaces.map((workspace) => ({
                id: workspace.id,
                label: workspace.name,
                icon: <Avatar name={workspace.name} size="xs" shape="square" decorative />,
                meta: workspace.plan,
                onSelect: () => {},
              })),
            },
            {
              items: [
                {
                  id: 'new',
                  label: 'Create workspace',
                  icon: <Plus className="size-4" />,
                  onSelect: () => {},
                },
              ],
            },
          ]}
          trigger={(props) => (
            <button
              type="button"
              className="flex h-9 items-center gap-2 rounded-md border border-line px-2.5 text-sm font-medium text-ink transition-colors hover:bg-surface-sunken"
              {...props}
            >
              <Avatar name={current?.name ?? 'Workspace'} size="xs" shape="square" decorative />
              <span className="max-w-32 truncate">{current?.name}</span>
              <Badge size="sm" tone="accent">
                {current?.plan}
              </Badge>
              <ChevronsUpDown className="size-3.5 text-ink-subtle" aria-hidden="true" />
            </button>
          )}
        />

        <nav aria-label="Workspace" className="hidden md:block">
          <ul className="flex items-center gap-1">
            {productLinks.slice(0, 3).map((link, index) => (
              <li key={link.href}>
                <Link
                  href={link.href}
                  aria-current={index === 0 ? 'page' : undefined}
                  className={
                    index === 0
                      ? 'flex h-8 items-center gap-1.5 rounded-md bg-surface-sunken px-3 text-sm font-medium text-ink-strong'
                      : 'flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink'
                  }
                >
                  {index === 0 ? (
                    <Check className="size-3.5 text-accent" aria-hidden="true" />
                  ) : null}
                  {link.label}
                </Link>
              </li>
            ))}
          </ul>
        </nav>

        <div className="ml-auto">
          <Avatar name="Priya Raman" size="sm" />
        </div>
      </div>

      <nav aria-label="Workspace, compact" className="border-t border-line-subtle px-4 md:hidden">
        <ul className="hide-scrollbar flex gap-1 overflow-x-auto py-2">
          {productLinks.map((link, index) => (
            <li key={link.href}>
              <Link
                href={link.href}
                aria-current={index === 0 ? 'page' : undefined}
                className={
                  index === 0
                    ? 'flex h-8 items-center rounded-md bg-surface-sunken px-3 text-sm font-medium whitespace-nowrap text-ink-strong'
                    : 'flex h-8 items-center rounded-md px-3 text-sm whitespace-nowrap text-ink-muted'
                }
              >
                {link.label}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
    </div>
  )
}

components/ui/dropdown.tsx

'use client'

import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'

/**
 * Dropdown menu
 *
 * The ARIA menu-button pattern: the trigger owns `aria-haspopup="menu"` and
 * `aria-expanded`, the panel is a `role="menu"` and roving focus moves between
 * `role="menuitem"` children with the arrow keys.
 *
 * Opening with ArrowUp focuses the last item, opening with ArrowDown or Enter
 * focuses the first — the small detail that makes a menu feel native.
 */
export interface DropdownItem {
  id: string
  label: ReactNode
  href?: string
  onSelect?: () => void
  icon?: ReactNode
  /** Right-aligned hint, e.g. a keyboard shortcut. */
  meta?: ReactNode
  disabled?: boolean
  tone?: 'default' | 'danger'
}

export interface DropdownGroup {
  label?: string
  items: DropdownItem[]
}

export interface DropdownProps {
  trigger: (props: {
    'aria-expanded': boolean
    'aria-haspopup': 'menu'
    'aria-controls': string
    onClick: () => void
    onKeyDown: (event: KeyboardEvent) => void
    ref: React.Ref<HTMLButtonElement>
  }) => ReactNode
  groups: DropdownGroup[]
  align?: 'start' | 'end'
  label?: string
  className?: string
  menuClassName?: string
}

export function Dropdown({
  trigger,
  groups,
  align = 'start',
  label = 'Menu',
  className,
  menuClassName,
}: DropdownProps) {
  const [open, setOpen] = useState(false)
  const rootRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const itemRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
  const uid = useId()
  const menuId = `${uid}-menu`

  const flatItems = groups.flatMap((group) => group.items).filter((item) => !item.disabled)

  const close = (focusTrigger = true) => {
    setOpen(false)
    if (focusTrigger) triggerRef.current?.focus()
  }

  useDismiss([rootRef], open, () => close(false))

  const openAt = (position: 'first' | 'last') => {
    setOpen(true)
    requestAnimationFrame(() => {
      const index = position === 'first' ? 0 : flatItems.length - 1
      itemRefs.current[index]?.focus()
    })
  }

  const onTriggerKeyDown = (event: KeyboardEvent) => {
    if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
      event.preventDefault()
      openAt('first')
    } else if (event.key === 'ArrowUp') {
      event.preventDefault()
      openAt('last')
    }
  }

  const onItemKeyDown = (event: KeyboardEvent, index: number) => {
    if (event.key === 'ArrowDown') {
      event.preventDefault()
      itemRefs.current[(index + 1) % flatItems.length]?.focus()
    } else if (event.key === 'ArrowUp') {
      event.preventDefault()
      itemRefs.current[(index - 1 + flatItems.length) % flatItems.length]?.focus()
    } else if (event.key === 'Home') {
      event.preventDefault()
      itemRefs.current[0]?.focus()
    } else if (event.key === 'End') {
      event.preventDefault()
      itemRefs.current[flatItems.length - 1]?.focus()
    } else if (event.key === 'Tab') {
      close(false)
    }
  }

  // Index every enabled item up front. Mutating a counter while rendering
  // would work today and break the moment rendering is interrupted.
  const itemIndex = new Map<string, number>()
  flatItems.forEach((item, index) => itemIndex.set(item.id, index))

  return (
    <div ref={rootRef} className={cn('relative inline-block', className)}>
      {trigger({
        'aria-expanded': open,
        'aria-haspopup': 'menu',
        'aria-controls': menuId,
        onClick: () => (open ? close() : openAt('first')),
        onKeyDown: onTriggerKeyDown,
        ref: triggerRef,
      })}

      {open ? (
        <div
          id={menuId}
          role="menu"
          aria-label={label}
          className={cn(
            'animate-scale-in absolute z-50 mt-1.5 min-w-56 rounded-lg border border-line bg-surface-raised p-1 shadow-md',
            align === 'end' ? 'right-0' : 'left-0',
            menuClassName,
          )}
        >
          {groups.map((group, groupIndex) => (
            <div
              key={group.label ?? `group-${groupIndex}`}
              role="group"
              aria-label={group.label}
              className={cn(groupIndex > 0 && 'mt-1 border-t border-line-subtle pt-1')}
            >
              {group.label ? (
                <p className="label-caps px-2.5 py-1.5 text-ink-subtle">{group.label}</p>
              ) : null}
              {group.items.map((item) => {
                const index = itemIndex.get(item.id) ?? -1
                const itemClass = cn(
                  'flex w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left text-sm transition-colors duration-150',
                  item.disabled
                    ? 'cursor-not-allowed text-ink-subtle opacity-60'
                    : item.tone === 'danger'
                      ? 'text-danger hover:bg-danger-soft'
                      : 'text-ink hover:bg-surface-sunken',
                )

                if (item.href && !item.disabled) {
                  return (
                    <Link
                      key={item.id}
                      href={item.href}
                      role="menuitem"
                      tabIndex={-1}
                      ref={(el) => {
                        itemRefs.current[index] = el
                      }}
                      onKeyDown={(event) => onItemKeyDown(event, index)}
                      onClick={() => close(false)}
                      className={itemClass}
                    >
                      {item.icon}
                      <span className="min-w-0 flex-1 truncate">{item.label}</span>
                      {item.meta ? (
                        <span className="shrink-0 font-mono text-2xs text-ink-subtle">
                          {item.meta}
                        </span>
                      ) : null}
                    </Link>
                  )
                }

                return (
                  <button
                    key={item.id}
                    type="button"
                    role="menuitem"
                    tabIndex={-1}
                    disabled={item.disabled}
                    ref={(el) => {
                      if (!item.disabled) itemRefs.current[index] = el
                    }}
                    onKeyDown={(event) => onItemKeyDown(event, index)}
                    onClick={() => {
                      item.onSelect?.()
                      close()
                    }}
                    className={itemClass}
                  >
                    {item.icon}
                    <span className="min-w-0 flex-1 truncate">{item.label}</span>
                    {item.meta ? (
                      <span className="shrink-0 font-mono text-2xs text-ink-subtle">
                        {item.meta}
                      </span>
                    ) : null}
                  </button>
                )
              })}
            </div>
          ))}
        </div>
      ) : null}
    </div>
  )
}

components/ui/avatar.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'

/**
 * Avatar / AvatarGroup
 *
 * Foundry ships no photography, so avatars render deterministic initials on a
 * tinted surface. The tint is derived from the name's character codes, which
 * keeps the same person the same colour on every page without a colour field
 * in the data.
 *
 * A decorative avatar next to a visible name is `aria-hidden`; a standalone
 * one exposes the name as its label.
 */
export interface AvatarProps {
  name: string
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  /** Suppresses the accessible name when the name is already on screen. */
  decorative?: boolean
  /** Small badge anchored bottom-right, e.g. a presence dot. */
  indicator?: ReactNode
  shape?: 'circle' | 'square'
  className?: string
}

const sizes = {
  xs: 'size-5 text-2xs',
  sm: 'size-7 text-2xs',
  md: 'size-9 text-xs',
  lg: 'size-12 text-sm',
  xl: 'size-16 text-lg',
} as const

const tints = [
  'bg-accent-soft text-accent-soft-ink',
  'bg-success-soft text-success',
  'bg-warning-soft text-warning',
  'bg-info-soft text-info',
  'bg-danger-soft text-danger',
  'bg-surface-sunken text-ink-muted',
] as const

function tintFor(name: string): string {
  let hash = 0
  for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
  return tints[hash % tints.length] ?? tints[0]
}

export function Avatar({
  name,
  size = 'md',
  decorative = false,
  indicator,
  shape = 'circle',
  className,
}: AvatarProps) {
  return (
    <span className={cn('relative inline-flex shrink-0', className)}>
      <span
        role={decorative ? undefined : 'img'}
        aria-label={decorative ? undefined : name}
        aria-hidden={decorative || undefined}
        className={cn(
          'inline-flex items-center justify-center border border-line font-semibold select-none',
          shape === 'circle' ? 'rounded-full' : 'rounded-md',
          sizes[size],
          tintFor(name),
        )}
      >
        {toInitials(name)}
      </span>
      {indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
    </span>
  )
}

export interface AvatarGroupProps {
  names: string[]
  size?: AvatarProps['size']
  /** Names beyond this count collapse into a "+n" chip. */
  max?: number
  className?: string
  label?: string
}

export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
  const visible = names.slice(0, max)
  const overflow = names.length - visible.length

  return (
    <span
      className={cn('flex items-center', className)}
      role="group"
      aria-label={label ?? `${names.length} people`}
    >
      {visible.map((name) => (
        <span
          key={name}
          className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
        >
          <Avatar name={name} size={size} decorative />
        </span>
      ))}
      {overflow > 0 ? (
        <span
          className={cn(
            '-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
            sizes[size],
          )}
        >
          +{overflow}
        </span>
      ) : null}
      <span className="sr-only">{names.join(', ')}</span>
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For accounts spanning several workspaces or products. Position is the design decision: placing the switcher anywhere but first invites people to act in the wrong workspace.

  • Show the plan alongside the name — it is the fastest way to answer "why can I not do this here".
  • Keep "Create workspace" in its own group, separated by a rule, so it is not selected by accident.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Workspace dropdown with plan
  • Create workspace action
  • Scrolling compact rail

Accessibility

Menu semantics
Built on the Dropdown primitive, so arrow keys, Home/End and Escape all work.
Truncation
Long workspace names truncate visually but remain complete in the accessible name.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.