UIPackage
Menu

Framework

Change language

Boilerplate repo

Enterprise Workspace Switcher

blocklayout

Multi-organization workspace switcher dropdown and creation modal inspired by Portico/Riter. Features active organization display with emerald ring, 3 tier levels (Enterprise Scale, Pro Team, Free Sandbox), team role badges, member counts, and an organization creation dialog with dynamic slug preview, region selector, and tier plan radios.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/enterprise-workspace-switcher.json
Named registry:npx shadcn@latest add @uipkge-react/enterprise-workspace-switcherInstalls to:components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
initialWorkspacesWorkspaceItem[]optional
defaultActiveIdstringoptional
userUserInfooptional
classNamestringoptional
onChange(workspace: WorkspaceItem) => voidoptional
onCreate(workspace: WorkspaceItem) => voidoptional
onAction(key: 'settings' | 'billing' | 'invite' | 'logout') => voidoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

WorkspaceItem
interface WorkspaceItem {
  id: string
  name: string
  slug?: string
  plan: string
  planTier: string
  members: number
  region: string
  regionCode: string
  role: 'Owner' | 'Admin' | 'Member' | 'Personal'
  color: string
  initials: string
}
UserInfo
interface UserInfo {
  name: string
  email: string
  avatar?: string
}

Files installed (1)

  • components/blocks/EnterpriseWorkspaceSwitcher.tsx20.2 kB
    'use client'
    
    import * as React from 'react'
    import { Building2, Check, ChevronsUpDown, Globe, LogOut, Plus, Settings, UserPlus } from 'lucide-react'
    import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
    } from '@/components/ui/dialog'
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuGroup,
      DropdownMenuItem,
      DropdownMenuLabel,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu'
    import { Input } from '@/components/ui/input'
    import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import { cn } from '@/lib/utils'
    
    export interface WorkspaceItem {
      id: string
      name: string
      slug?: string
      plan: string
      planTier: string
      members: number
      region: string
      regionCode: string
      role: 'Owner' | 'Admin' | 'Member' | 'Personal'
      color: string
      initials: string
    }
    
    export interface UserInfo {
      name: string
      email: string
      avatar?: string
    }
    
    export interface EnterpriseWorkspaceSwitcherProps {
      initialWorkspaces?: WorkspaceItem[]
      defaultActiveId?: string
      user?: UserInfo
      className?: string
      onChange?: (workspace: WorkspaceItem) => void
      onCreate?: (workspace: WorkspaceItem) => void
      onAction?: (key: 'settings' | 'billing' | 'invite' | 'logout') => void
    }
    
    const DEFAULT_WORKSPACES: WorkspaceItem[] = [
      {
        id: 'ws-1',
        name: 'UIPKGE Enterprise Inc.',
        slug: 'uipkge-enterprise',
        plan: 'Enterprise Scale',
        planTier: 'Enterprise Plan',
        members: 64,
        region: 'US East (N. Virginia)',
        regionCode: 'US-East',
        role: 'Owner',
        color: 'bg-primary/10 text-primary border border-primary/20',
        initials: 'UE',
      },
      {
        id: 'ws-2',
        name: 'Acme Design Systems Lab',
        slug: 'acme-design',
        plan: 'Pro Team',
        planTier: 'Pro Team',
        members: 18,
        region: 'EU Central (Frankfurt)',
        regionCode: 'EU-Central',
        role: 'Admin',
        color: 'bg-info/10 text-info border border-info/20',
        initials: 'AD',
      },
      {
        id: 'ws-3',
        name: 'Personal Sandbox',
        slug: 'personal-sandbox',
        plan: 'Developer Tier',
        planTier: 'Free Developer Tier',
        members: 1,
        region: 'Global Edge',
        regionCode: 'Global',
        role: 'Personal',
        color: 'bg-success/10 text-success border border-success/20',
        initials: 'PS',
      },
    ]
    
    const DEFAULT_USER: UserInfo = {
      name: 'Elena Rostova',
      email: '[email protected]',
      avatar: '',
    }
    
    export function EnterpriseWorkspaceSwitcher({
      initialWorkspaces = DEFAULT_WORKSPACES,
      defaultActiveId = 'ws-1',
      user = DEFAULT_USER,
      className,
      onChange,
      onCreate,
      onAction,
    }: EnterpriseWorkspaceSwitcherProps) {
      const [workspaces, setWorkspaces] = React.useState<WorkspaceItem[]>(initialWorkspaces)
      const [activeWorkspaceId, setActiveWorkspaceId] = React.useState<string>(defaultActiveId)
      const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
    
      // Dialog Form State
      const [newOrgName, setNewOrgName] = React.useState('Stripe Developer Platform')
      const [newOrgRegion, setNewOrgRegion] = React.useState('us-east')
      const [newOrgPlan, setNewOrgPlan] = React.useState('pro')
    
      const activeWorkspace = React.useMemo(() => {
        return (
          workspaces.find((w) => w.id === activeWorkspaceId) ||
          workspaces[0] || {
            id: 'ws-fallback',
            name: 'Workspace',
            plan: 'Standard',
            planTier: 'Standard',
            members: 1,
            region: 'US-East',
            regionCode: 'US-East',
            role: 'Owner',
            color: 'bg-muted text-foreground border border-border',
            initials: 'WS',
          }
        )
      }, [workspaces, activeWorkspaceId])
    
      const slugPreview = React.useMemo(() => {
        const cleaned = newOrgName
          .trim()
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/^-+|-+$/g, '')
        return cleaned || 'stripe-dev'
      }, [newOrgName])
    
      const handleSelectWorkspace = (workspace: WorkspaceItem) => {
        setActiveWorkspaceId(workspace.id)
        onChange?.(workspace)
      }
    
      const handleCreateWorkspace = (e: React.FormEvent) => {
        e.preventDefault()
        if (!newOrgName.trim()) return
    
        const regionMap: Record<string, { full: string; code: string }> = {
          'us-east': { full: 'US East (N. Virginia)', code: 'US-East' },
          'eu-central': { full: 'EU Central (Frankfurt)', code: 'EU-Central' },
          'ap-south': { full: 'AP South (Mumbai)', code: 'AP-South' },
        }
    
        const regionInfo = regionMap[newOrgRegion] || {
          full: 'US East (N. Virginia)',
          code: 'US-East',
        }
    
        const parts = newOrgName.trim().split(/\s+/)
        const initials =
          parts.length > 1
            ? `${parts[0]?.[0] || ''}${parts[1]?.[0] || ''}`.toUpperCase()
            : (parts[0]?.slice(0, 2) || 'WS').toUpperCase()
    
        const newWs: WorkspaceItem = {
          id: `ws-${Date.now()}`,
          name: newOrgName.trim(),
          slug: slugPreview,
          plan: newOrgPlan === 'enterprise' ? 'Enterprise Scale' : 'Pro Team',
          planTier: newOrgPlan === 'enterprise' ? 'Enterprise Custom' : 'Pro Team',
          members: 1,
          region: regionInfo.full,
          regionCode: regionInfo.code,
          role: 'Owner',
          color:
            newOrgPlan === 'enterprise'
              ? 'bg-primary/10 text-primary border border-primary/20'
              : 'bg-info/10 text-info border border-info/20',
          initials,
        }
    
        setWorkspaces((prev) => [newWs, ...prev])
        setActiveWorkspaceId(newWs.id)
        onCreate?.(newWs)
        onChange?.(newWs)
    
        setNewOrgName('')
        setIsCreateDialogOpen(false)
      }
    
      const userInitials = React.useMemo(() => {
        return (
          user.name
            .split(' ')
            .map((p) => p[0])
            .join('')
            .toUpperCase() || 'U'
        )
      }, [user.name])
    
      return (
        <div data-slot="enterprise-workspace-switcher" className={cn('w-full', className)}>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <button
                type="button"
                className="group border-border/80 bg-card hover:bg-accent/40 focus-visible:ring-ring flex w-full items-center gap-3 rounded-xl border p-2.5 text-left shadow-xs transition-[background-color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:outline-none"
                aria-label="Select organization workspace"
              >
                {/* Org Avatar / Monogram with Emerald Ring */}
                <div className="relative size-10 shrink-0">
                  <div
                    className={cn(
                      'flex size-10 items-center justify-center rounded-lg text-sm font-semibold shadow-2xs',
                      activeWorkspace.color,
                    )}
                  >
                    {activeWorkspace.initials}
                  </div>
                  <span
                    className="border-card bg-success absolute -top-0.5 -right-0.5 size-2.5 rounded-full border-2"
                    title="Active Organization"
                    aria-label="Active workspace status: Online"
                  />
                </div>
    
                {/* Active Org Info & Role Badge */}
                <div className="min-w-0 flex-1">
                  <div className="flex items-center gap-1.5">
                    <span className="text-foreground truncate text-sm font-semibold tracking-tight">
                      {activeWorkspace.name}
                    </span>
                    <Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-xs font-medium">
                      {activeWorkspace.role}
                    </Badge>
                  </div>
                  <div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 truncate text-xs">
                    <span className="truncate font-medium">Tier: {activeWorkspace.plan}</span>
                    <span className="shrink-0">{activeWorkspace.regionCode}</span>
                  </div>
                </div>
    
                {/* Chevron Icon */}
                <ChevronsUpDown className="text-muted-foreground/70 group-hover:text-foreground size-4 shrink-0 transition-colors" />
              </button>
            </DropdownMenuTrigger>
    
            <DropdownMenuContent
              className="border-border bg-popover w-80 rounded-xl p-2 shadow-xl sm:w-96"
              align="start"
              sideOffset={6}
            >
              {/* User Header Profile Info */}
              <DropdownMenuLabel className="p-0 font-normal">
                <div className="bg-muted/40 flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left">
                  <Avatar className="size-8 rounded-full border">
                    {user.avatar ? <AvatarImage src={user.avatar} alt={user.name} /> : null}
                    <AvatarFallback className="bg-primary/10 text-primary text-xs font-bold">{userInitials}</AvatarFallback>
                  </Avatar>
                  <div className="grid min-w-0 flex-1 text-left leading-tight">
                    <span className="text-muted-foreground text-xs font-medium">Signed in as</span>
                    <span className="text-foreground truncate text-xs font-semibold">{user.email}</span>
                  </div>
                  <div className="flex items-center gap-1">
                    <span className="bg-success size-2 rounded-full" />
                    <span className="text-muted-foreground text-xs font-medium">Ready</span>
                  </div>
                </div>
              </DropdownMenuLabel>
    
              <DropdownMenuSeparator className="my-1.5" />
    
              {/* Workspaces Section */}
              <div className="flex items-center justify-between px-2 py-1">
                <span className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Workspaces</span>
                <span className="text-muted-foreground/70 text-xs">{workspaces.length} total</span>
              </div>
    
              <div className="my-1 space-y-1">
                {workspaces.map((ws) => (
                  <DropdownMenuItem
                    key={ws.id}
                    className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors"
                    onSelect={() => handleSelectWorkspace(ws)}
                  >
                    <div
                      className={cn(
                        'flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold shadow-2xs',
                        ws.color,
                      )}
                    >
                      {ws.initials}
                    </div>
    
                    <div className="min-w-0 flex-1">
                      <div className="flex items-center gap-1.5">
                        <span
                          className={cn(
                            'truncate text-sm font-medium',
                            activeWorkspaceId === ws.id ? 'text-foreground font-semibold' : 'text-foreground/90',
                          )}
                        >
                          {ws.name}
                        </span>
                        {ws.role === 'Owner' && (
                          <Badge variant="secondary" className="shrink-0 px-1 py-0 text-xs font-medium">
                            Owner
                          </Badge>
                        )}
                        {ws.role === 'Admin' && (
                          <Badge variant="outline" className="shrink-0 px-1 py-0 text-xs font-medium">
                            Admin
                          </Badge>
                        )}
                      </div>
                      <p className="text-muted-foreground truncate text-xs">
                        {ws.planTier} · {ws.members} Member{ws.members === 1 ? '' : 's'} · {ws.regionCode}
                      </p>
                    </div>
    
                    {activeWorkspaceId === ws.id && <Check className="text-success size-4 shrink-0" aria-hidden="true" />}
                  </DropdownMenuItem>
                ))}
              </div>
    
              <DropdownMenuSeparator className="my-1.5" />
    
              {/* Actions */}
              <DropdownMenuGroup>
                <DropdownMenuItem
                  className="text-foreground hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"
                  onSelect={() => setIsCreateDialogOpen(true)}
                >
                  <div className="border-primary/30 bg-primary/10 text-primary flex size-6 items-center justify-center rounded-md border">
                    <Plus className="size-3.5" />
                  </div>
                  <span className="font-medium">Create New Workspace</span>
                </DropdownMenuItem>
    
                <DropdownMenuItem
                  className="text-muted-foreground hover:text-foreground hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"
                  onSelect={() => onAction?.('settings')}
                >
                  <div className="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                    <Settings className="size-3.5" />
                  </div>
                  <span>Organization Settings & Billing</span>
                </DropdownMenuItem>
    
                <DropdownMenuItem
                  className="text-muted-foreground hover:text-foreground hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"
                  onSelect={() => onAction?.('invite')}
                >
                  <div className="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                    <UserPlus className="size-3.5" />
                  </div>
                  <span>Invite Team Members</span>
                </DropdownMenuItem>
              </DropdownMenuGroup>
    
              <DropdownMenuSeparator className="my-1.5" />
    
              {/* Logout */}
              <DropdownMenuItem
                className="text-destructive hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors"
                onSelect={() => onAction?.('logout')}
              >
                <div className="border-destructive/20 bg-destructive/10 text-destructive flex size-6 items-center justify-center rounded-md border">
                  <LogOut className="size-3.5" />
                </div>
                <span>Log out</span>
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
    
          {/* Create New Workspace Modal Dialog */}
          <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
            <DialogContent className="border-border bg-background rounded-2xl p-6 shadow-sm sm:max-w-lg">
              <DialogHeader className="space-y-2 text-left">
                <div className="border-primary/20 bg-primary/10 text-primary flex size-10 items-center justify-center rounded-xl border shadow-xs">
                  <Building2 className="size-5" />
                </div>
                <DialogTitle className="text-foreground text-lg font-bold tracking-tight">Create New Workspace</DialogTitle>
                <DialogDescription className="text-muted-foreground text-sm leading-relaxed">
                  Spin up a dedicated organization workspace with multi-region routing and isolated billing.
                </DialogDescription>
              </DialogHeader>
    
              <form className="space-y-4 py-2" onSubmit={handleCreateWorkspace}>
                {/* Workspace Name */}
                <div className="space-y-1.5">
                  <label htmlFor="new-workspace-name-react" className="text-foreground text-sm font-medium">
                    Workspace Name
                  </label>
                  <Input
                    id="new-workspace-name-react"
                    value={newOrgName}
                    onChange={(e) => setNewOrgName(e.target.value)}
                    placeholder="e.g. Stripe Developer Platform"
                    autoComplete="off"
                    className="h-10 text-sm"
                    required
                  />
                </div>
    
                {/* URL Slug Preview */}
                <div className="space-y-1.5">
                  <label className="text-muted-foreground text-xs font-medium">Workspace URL Slug</label>
                  <div className="border-border bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
                    <Globe className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
                    <span className="text-muted-foreground">uipkge.dev/</span>
                    <span className="text-foreground truncate font-semibold">{slugPreview}</span>
                  </div>
                </div>
    
                {/* Primary Region Selection */}
                <div className="space-y-1.5">
                  <label className="text-foreground text-sm font-medium">Primary Region</label>
                  <Select value={newOrgRegion} onValueChange={setNewOrgRegion}>
                    <SelectTrigger className="h-10 w-full">
                      <SelectValue placeholder="Select primary region" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="us-east">US East (N. Virginia)</SelectItem>
                      <SelectItem value="eu-central">EU Central (Frankfurt)</SelectItem>
                      <SelectItem value="ap-south">AP South (Mumbai)</SelectItem>
                    </SelectContent>
                  </Select>
                  <p className="text-muted-foreground text-xs">
                    Primary cluster location for low-latency queries and database replication.
                  </p>
                </div>
    
                {/* Plan Selection Radios */}
                <div className="space-y-2">
                  <label className="text-foreground text-sm font-medium">Choose Workspace Plan</label>
                  <RadioGroup
                    value={newOrgPlan}
                    onValueChange={setNewOrgPlan}
                    className="grid grid-cols-1 gap-3 sm:grid-cols-2"
                  >
                    <label
                      htmlFor="plan-pro-react"
                      className={cn(
                        'border-border/80 hover:border-primary/50 relative flex cursor-pointer flex-col justify-between rounded-xl border p-3.5 shadow-xs transition-colors',
                        newOrgPlan === 'pro' && 'border-primary bg-primary/5 ring-primary/40 shadow-sm ring-1',
                      )}
                    >
                      <div className="flex items-start justify-between gap-2">
                        <div className="space-y-0.5">
                          <span className="text-foreground text-sm font-semibold">Pro Team</span>
                          <p className="text-primary text-xs font-medium">$29 / month</p>
                        </div>
                        <RadioGroupItem id="plan-pro-react" value="pro" className="mt-0.5" />
                      </div>
                      <p className="text-muted-foreground mt-2 text-xs leading-relaxed">
                        Up to 25 members, standard 99.9% SLA, and daily automated backups.
                      </p>
                    </label>
    
                    <label
                      htmlFor="plan-enterprise-react"
                      className={cn(
                        'border-border/80 hover:border-primary/50 relative flex cursor-pointer flex-col justify-between rounded-xl border p-3.5 shadow-xs transition-colors',
                        newOrgPlan === 'enterprise' && 'border-primary bg-primary/5 ring-primary/40 shadow-sm ring-1',
                      )}
                    >
                      <div className="flex items-start justify-between gap-2">
                        <div className="space-y-0.5">
                          <span className="text-foreground text-sm font-semibold">Enterprise Scale</span>
                          <p className="text-primary text-xs font-medium">Custom billing</p>
                        </div>
                        <RadioGroupItem id="plan-enterprise-react" value="enterprise" className="mt-0.5" />
                      </div>
                      <p className="text-muted-foreground mt-2 text-xs leading-relaxed">
                        Unlimited members, dedicated VPC peering, 99.99% uptime SLA & SSO.
                      </p>
                    </label>
                  </RadioGroup>
                </div>
    
                <DialogFooter className="border-border/60 flex gap-2 border-t pt-4 sm:justify-end">
                  <Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
                    Cancel
                  </Button>
                  <Button type="submit" disabled={!newOrgName.trim()}>
                    Create Workspace
                  </Button>
                </DialogFooter>
              </form>
            </DialogContent>
          </Dialog>
        </div>
      )
    }
    
    export default EnterpriseWorkspaceSwitcher
    

Raw manifest:https://uipkge.dev/r/react/enterprise-workspace-switcher.json