{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "launchpad",
  "title": "Launchpad",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The macOS Launchpad: a frosted full-area overlay with search, pages of app icons sized to fit, page dots, wheel and key paging, arrow-key focus across pages and Escape to close.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/launchpad.tsx",
      "content": "// Ballmac UI: Launchpad. https://ui.ballmac.com/components/launchpad\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Search } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\n\ntype LaunchpadApp = {\n  /** Unique id. */\n  id: string\n  /** Name under the icon. */\n  name: string\n  /** The icon, usually an AppIcon. */\n  icon: React.ReactNode\n}\n\ntype LaunchpadProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The apps to show, in order. */\n  apps: LaunchpadApp[]\n  /** Whether the launcher is showing. */\n  open: boolean\n  /** Called when the launcher asks to close (Escape, or a click on the empty background). */\n  onClose?: () => void\n  /** Called when an app is chosen. */\n  onLaunch?: (app: LaunchpadApp) => void\n  /** Width of one app cell in pixels. */\n  cellWidth?: number\n  /** Height of one app cell in pixels. */\n  cellHeight?: number\n  /** Accessible name of the launcher. */\n  label?: string\n}\n\n/**\n * The macOS Launchpad: a frosted overlay with a search field, pages of apps and page dots.\n * It fills its positioned parent. Arrow keys move between apps and across pages, PageUp and PageDown turn pages,\n * and typing filters. Escape closes it.\n */\nfunction Launchpad({ apps, open, onClose, onLaunch, cellWidth = 120, cellHeight = 112, label, className, ...props }: LaunchpadProps) {\n  const dir = useDirection()\n  const msg = useMessages()\n  label ??= msg(\"launchpad.label\", \"Launchpad\")\n  const reduce = useReducedMotion()\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const searchRef = React.useRef<HTMLInputElement>(null)\n  const [size, setSize] = React.useState({ w: 800, h: 560 })\n  const [query, setQuery] = React.useState(\"\")\n  const [page, setPage] = React.useState(0)\n  const [focus, setFocus] = React.useState(0)\n  const wheel = React.useRef(0)\n\n  React.useEffect(() => {\n    const el = rootRef.current\n    if (!el || !open) return\n    const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight })\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [open])\n\n  React.useEffect(() => {\n    if (open) {\n      setQuery(\"\")\n      setPage(0)\n      setFocus(0)\n      const id = requestAnimationFrame(() => searchRef.current?.focus())\n      return () => cancelAnimationFrame(id)\n    }\n  }, [open])\n\n  const cols = Math.max(2, Math.min(8, Math.floor((size.w - 48) / cellWidth)))\n  const rows = Math.max(1, Math.min(5, Math.floor((size.h - 170) / cellHeight)))\n  const perPage = cols * rows\n  const shown = apps.filter((a) => !query || a.name.toLowerCase().includes(query.toLowerCase()))\n  const pages = Math.max(1, Math.ceil(shown.length / perPage))\n  const current = Math.min(page, pages - 1)\n\n  function goPage(next: number) {\n    setPage(Math.min(pages - 1, Math.max(0, next)))\n  }\n  function focusApp(index: number) {\n    const i = Math.min(shown.length - 1, Math.max(0, index))\n    setFocus(i)\n    setPage(Math.floor(i / perPage))\n    requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[data-app-index=\"${i}\"]`)?.focus())\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"Escape\") {\n      e.preventDefault()\n      onClose?.()\n    } else if (e.key === \"PageDown\" || (e.key === (dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\") && e.ctrlKey)) {\n      e.preventDefault()\n      goPage(current + 1)\n    } else if (e.key === \"PageUp\" || (e.key === (dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\") && e.ctrlKey)) {\n      e.preventDefault()\n      goPage(current - 1)\n    }\n  }\n\n  function onAppKey(e: React.KeyboardEvent, index: number) {\n    const move: Record<string, number> = { ArrowRight: dir === \"rtl\" ? -1 : 1, ArrowLeft: dir === \"rtl\" ? 1 : -1, ArrowDown: cols, ArrowUp: -cols }\n    if (e.key in move && !e.ctrlKey) {\n      e.preventDefault()\n      focusApp(index + move[e.key]!)\n    } else if (e.key === \"Home\") {\n      e.preventDefault()\n      focusApp(0)\n    } else if (e.key === \"End\") {\n      e.preventDefault()\n      focusApp(shown.length - 1)\n    }\n  }\n\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          ref={rootRef}\n          data-slot=\"launchpad\"\n          role=\"dialog\"\n          aria-label={label}\n          onKeyDown={onKeyDown}\n          onWheel={(e) => {\n            wheel.current += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY\n            if (Math.abs(wheel.current) > 120) {\n              goPage(current + Math.sign(wheel.current))\n              wheel.current = 0\n            }\n          }}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.12, filter: \"blur(10px)\" }}\n          animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.12, filter: \"blur(10px)\" }}\n          transition={{ duration: reduce ? 0.12 : 0.32, ease: [0.22, 1, 0.36, 1] }}\n          className={cn(\"absolute inset-0 z-40 flex flex-col items-center overflow-hidden bg-black/45 text-white backdrop-blur-3xl backdrop-saturate-150\", className)}\n          {...(props as object)}\n          onClick={(e) => {\n            if (e.target === e.currentTarget) onClose?.()\n          }}\n        >\n          <div className=\"relative mt-5 w-56 max-w-[70%] shrink-0\">\n            <Search aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 start-2.5 size-3.5 -translate-y-1/2 text-white/70\" />\n            <input\n              ref={searchRef}\n              type=\"search\"\n              value={query}\n              aria-label={msg(\"launchpad.searchApps\", \"Search apps\")}\n              placeholder={msg(\"launchpad.search\", \"Search\")}\n              onChange={(e) => {\n                setQuery(e.target.value)\n                setPage(0)\n                setFocus(0)\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && shown[0]) onLaunch?.(shown[0])\n                else if (e.key === \"ArrowDown\" && shown.length) {\n                  e.preventDefault()\n                  focusApp(0)\n                }\n              }}\n              className=\"h-7 w-full rounded-lg border border-white/20 bg-white/15 pe-3 ps-8 text-center text-[13px] text-white outline-none placeholder:text-white/70 focus-visible:bg-white/25 focus-visible:ring-[3px] focus-visible:ring-white/40 [&::-webkit-search-cancel-button]:hidden\"\n            />\n          </div>\n\n          <div className=\"relative min-h-0 w-full flex-1 overflow-hidden\" onClick={(e) => e.target === e.currentTarget && onClose?.()}>\n            <motion.div\n              className=\"flex h-full\"\n              style={{ width: `${pages * 100}%` }}\n              animate={{ x: `${(-current * 100) / pages}%` }}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n            >\n              {Array.from({ length: pages }, (_, p) => (\n                <div key={p} className=\"flex h-full items-center justify-center\" style={{ width: `${100 / pages}%` }} aria-hidden={p !== current ? true : undefined} {...(p !== current ? { inert: true } : {})}>\n                  <ul className=\"grid\" style={{ gridTemplateColumns: `repeat(${cols}, ${cellWidth}px)`, gridAutoRows: `${cellHeight}px` }} aria-label={msg(\"launchpad.pageOf\", \"Page {n} of {pages}\", { n: p + 1, pages })}>\n                    {shown.slice(p * perPage, (p + 1) * perPage).map((app, i) => {\n                      const index = p * perPage + i\n                      return (\n                        <li key={app.id} className=\"flex items-start justify-center\">\n                          <button\n                            type=\"button\"\n                            data-app-index={index}\n                            tabIndex={focus === index ? 0 : -1}\n                            onFocus={() => setFocus(index)}\n                            onKeyDown={(e) => onAppKey(e, index)}\n                            onClick={() => onLaunch?.(app)}\n                            className=\"group/app flex w-[104px] flex-col items-center gap-2 rounded-xl p-1.5 outline-none focus-visible:bg-white/15 focus-visible:ring-[3px] focus-visible:ring-white/50\"\n                          >\n                            <span aria-hidden=\"true\" className=\"transition-transform duration-150 group-hover/app:scale-105 group-active/app:scale-95 motion-reduce:transition-none\">{app.icon}</span>\n                            <span className=\"line-clamp-1 text-[12px] leading-4 font-medium [text-shadow:0_1px_3px_rgb(0_0_0/0.7)]\">{app.name}</span>\n                          </button>\n                        </li>\n                      )\n                    })}\n                  </ul>\n                </div>\n              ))}\n            </motion.div>\n            {shown.length === 0 && <p className=\"absolute inset-0 flex items-center justify-center text-sm text-white/80\">{msg(\"launchpad.noAppsMatch\", \"No apps match “{query}”.\", { query })}</p>}\n          </div>\n\n          <div className=\"flex h-9 shrink-0 items-center gap-2\" role=\"group\" aria-label={msg(\"launchpad.pages\", \"Pages\")}>\n            {pages > 1 &&\n              Array.from({ length: pages }, (_, p) => (\n                <button\n                  key={p}\n                  type=\"button\"\n                  aria-label={msg(\"launchpad.page\", \"Page {n}\", { n: p + 1 })}\n                  aria-current={p === current ? \"true\" : undefined}\n                  onClick={() => goPage(p)}\n                  className={cn(\"size-2 rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-white\", p === current ? \"bg-white\" : \"bg-white/40 hover:bg-white/70\")}\n                />\n              ))}\n          </div>\n          <p className=\"sr-only\" role=\"status\">\n            {msg(\"launchpad.summary\", { one: \"{count} app, page {page} of {pages}\", other: \"{count} apps, page {page} of {pages}\" }, { count: shown.length, page: current + 1, pages })}\n          </p>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  )\n}\n\nexport { Launchpad, type LaunchpadProps, type LaunchpadApp }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/launchpad.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "launchpad",
      "apps",
      "grid",
      "overlay",
      "macos"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<Launchpad apps={[{ id, name, icon }]} open onClose onLaunch />. It fills its positioned parent. Pages, rows and columns are computed from the available size.",
      "whenToUse": [
        "Mac desktop demos",
        "App launchers in dashboards"
      ],
      "whenNotToUse": [
        "Command palettes (command)",
        "Navigation menus"
      ],
      "composesWith": [
        "dock",
        "mac-icons",
        "spotlight-search"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move between apps across pages"
        },
        {
          "keys": "PageUp / PageDown",
          "action": "Turn pages"
        },
        {
          "keys": "Enter in search",
          "action": "Launches the first match"
        },
        {
          "keys": "Escape",
          "action": "Closes"
        },
        {
          "keys": "Reduced motion",
          "action": "No zoom or blur on open"
        }
      ],
      "customization": [
        "cellWidth and cellHeight",
        "label",
        "open / onClose"
      ]
    },
    "examples": [
      "launchpad-demo",
      "launchpad-many"
    ],
    "url": "https://ui.ballmac.com/components/launchpad"
  },
  "categories": [
    "macos"
  ],
  "type": "registry:ui"
}