Menu

Change language

GitHub 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.

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
defaultActiveIdstring—optional
userUserInfo—optional
classNamestring—optional
onChange(workspace: WorkspaceItem) => void—optional
onCreate(workspace: WorkspaceItem) => void—optional
onAction(key: 'settings' | 'billing' | 'invite' | 'logout') => void—optional

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: 'elena@uipkge.dev',
      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