Product switcher
A workspace switcher in the leading position, because it changes the meaning of everything after it.
Live preview
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.
Related
All navigationDashboard shell
Top bar plus persistent sidebar, with the same destinations rendered into a drawer below lg.
intermediateFeatured4 variantsDropdown menu
The ARIA menu-button pattern with grouped items, roving focus, shortcuts and destructive styling.
intermediateFeatured6 variantsSaaS starter
A twelve-route subscription product: marketing site, authentication, an in-product dashboard and account management, all from the same component set.
advancedFeatured4 variants