App Switcher
Navigation

App Switcher

Nine-dot product launcher popover — brand-tinted tiles stagger in and form a 2D roving-focus grid with arrow-key navigation, Home/End jumps, and an aria-current ring on the active app.

Install

npx shadcn@latest add @paragon/app-switcher

Also installs: popover

app-switcher.tsx

"use client";

import * as React from "react";
import {
  ChartPie,
  CreditCard,
  FileText,
  Inbox,
  LifeBuoy,
  Rocket,
  ShieldCheck,
  Users,
  Workflow,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/paragon/ui/popover";

const switcherStyles = `
@keyframes pg-app-tile {
  from { opacity: 0; translate: 0 6px; filter: blur(3px); }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-app-tile { from { opacity: 0; } }
}
`;

export interface AppSwitcherApp {
  id: string;
  name: string;
  icon?: React.ReactNode;
  /** Tile accent color (any CSS color). */
  color?: string;
  href?: string;
  onSelect?: () => void;
  /** Marks the app the user is currently in. */
  current?: boolean;
}

export interface AppSwitcherProps {
  apps?: AppSwitcherApp[];
  /** Tiles per row. */
  columns?: number;
  /** Per-tile entrance stagger, ms. Decorative — tiles are usable at once. */
  stagger?: number;
  /** Fires with the chosen app; the popover closes itself. */
  onAppSelect?: (app: AppSwitcherApp) => void;
  /** Custom trigger — replaces the nine-dot button. */
  children?: React.ReactNode;
  side?: React.ComponentProps<typeof PopoverContent>["side"];
  align?: React.ComponentProps<typeof PopoverContent>["align"];
}

const defaultApps: AppSwitcherApp[] = [
  { id: "analytics", name: "Analytics", icon: <ChartPie />, color: "#4D80E6" },
  { id: "billing", name: "Billing", icon: <CreditCard />, color: "#0ea5e9", current: true },
  { id: "crm", name: "CRM", icon: <Users />, color: "#f59e0b" },
  { id: "docs", name: "Docs", icon: <FileText />, color: "#10b981" },
  { id: "inbox", name: "Inbox", icon: <Inbox />, color: "#8b5cf6" },
  { id: "deploy", name: "Deploy", icon: <Rocket />, color: "#f43f5e" },
  { id: "flows", name: "Flows", icon: <Workflow />, color: "#14b8a6" },
  { id: "vault", name: "Vault", icon: <ShieldCheck />, color: "#64748b" },
  { id: "support", name: "Support", icon: <LifeBuoy />, color: "#d946ef" },
];

/**
 * The nine-dot product launcher. Tiles carry brand-tinted glyphs (generated
 * locally — no remote images), stagger in with the house enter, and form a
 * true 2D roving-focus grid: one tab stop, arrow keys move by row and
 * column, Home/End jump the edges, Enter launches. The current app is
 * ringed and exposed via aria-current. Esc, outside click, and focus return
 * come from the popover engine.
 */
export function AppSwitcher({
  apps = defaultApps,
  columns = 3,
  stagger = 20,
  onAppSelect,
  children,
  side = "bottom",
  align = "end",
}: AppSwitcherProps) {
  const [open, setOpen] = React.useState(false);
  const [focusIndex, setFocusIndex] = React.useState(0);
  const tileRefs = React.useRef<(HTMLElement | null)[]>([]);

  // The roving tab stop starts on the current app.
  React.useEffect(() => {
    if (!open) return;
    const currentIdx = apps.findIndex((app) => app.current);
    setFocusIndex(currentIdx === -1 ? 0 : currentIdx);
  }, [open, apps]);

  const moveFocus = (next: number) => {
    const clamped = Math.max(0, Math.min(apps.length - 1, next));
    setFocusIndex(clamped);
    tileRefs.current[clamped]?.focus();
  };

  const onGridKeyDown = (event: React.KeyboardEvent) => {
    const keyMap: Record<string, number> = {
      ArrowRight: focusIndex + 1,
      ArrowLeft: focusIndex - 1,
      ArrowDown: focusIndex + columns,
      ArrowUp: focusIndex - columns,
      Home: 0,
      End: apps.length - 1,
    };
    const target = keyMap[event.key];
    if (target === undefined) return;
    event.preventDefault();
    moveFocus(target);
  };

  const launch = (app: AppSwitcherApp) => {
    setOpen(false);
    app.onSelect?.();
    onAppSelect?.(app);
  };

  return (
    <>
      <style href="paragon-app-switcher" precedence="paragon">
        {switcherStyles}
      </style>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          {children ?? (
            <button
              type="button"
              aria-label="Switch app"
              className={cn(
                "pressable relative flex size-9 items-center justify-center rounded-lg text-muted-foreground outline-none",
                "transition-colors duration-150 ease-out",
                "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
                "hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
                "data-[state=open]:bg-accent data-[state=open]:text-foreground",
              )}
            >
              {/* Nine-dot launcher glyph, drawn locally. */}
              <svg viewBox="0 0 16 16" className="size-4" aria-hidden>
                {[2, 8, 14].flatMap((y) =>
                  [2, 8, 14].map((x) => (
                    <circle key={`${x}-${y}`} cx={x} cy={y} r="1.4" fill="currentColor" />
                  )),
                )}
              </svg>
            </button>
          )}
        </PopoverTrigger>
        <PopoverContent side={side} align={align} className="w-auto p-2">
          <div
            role="grid"
            aria-label="Products"
            onKeyDown={onGridKeyDown}
            className="grid gap-1"
            style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
          >
            {apps.map((app, index) => {
              const accent = app.color ?? "#4D80E6";
              return (
                <button
                  key={app.id}
                  ref={(el) => {
                    tileRefs.current[index] = el;
                  }}
                  type="button"
                  role="gridcell"
                  tabIndex={index === focusIndex ? 0 : -1}
                  aria-current={app.current ? "true" : undefined}
                  onFocus={() => setFocusIndex(index)}
                  onClick={() => launch(app)}
                  style={{
                    animation: `pg-app-tile 200ms var(--ease-out) ${index * stagger}ms both`,
                  }}
                  className={cn(
                    "group relative flex w-[4.5rem] flex-col items-center gap-1.5 rounded-lg px-1 pt-2.5 pb-2 outline-none",
                    "transition-[background-color,scale] duration-150 ease-[var(--ease-out)]",
                    "hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
                    "active:not-disabled:scale-[0.97] motion-reduce:active:scale-100",
                  )}
                >
                  <span
                    aria-hidden
                    className={cn(
                      "flex size-9 items-center justify-center rounded-lg",
                      "[&_svg]:size-4.5 [&_svg]:shrink-0",
                      app.current && "ring-1 ring-current",
                    )}
                    style={{
                      color: accent,
                      backgroundColor: `color-mix(in oklab, ${accent} 13%, transparent)`,
                    }}
                  >
                    {app.icon ?? (
                      <span className="text-sm font-bold">
                        {app.name.slice(0, 1).toUpperCase()}
                      </span>
                    )}
                  </span>
                  <span className="w-full truncate text-center text-[11px] leading-3.5 font-medium">
                    {app.name}
                  </span>
                  {app.current && <span className="sr-only">(current app)</span>}
                </button>
              );
            })}
          </div>
        </PopoverContent>
      </Popover>
    </>
  );
}