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.
npx shadcn@latest add @paragon/app-switcherAlso installs: popover
"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>
</>
);
}