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.
Pick an app to launch it.
"use client"
import * as React from "react"
import { Calculator, Calendar, Camera, Compass, Film, Mail, Map, MessageCircle, Music, Notebook, Settings, Terminal } from "lucide-react"
import { AppIcon, type IconTone } from "@/components/ballmac/mac-icons"
import { Launchpad, type LaunchpadApp } from "@/components/ballmac/launchpad"
const spec: [string, IconTone, React.ReactNode][] = [
["Safari", "blue", <Compass key="a" />],
["Mail", "teal", <Mail key="b" />],
["Messages", "green", <MessageCircle key="c" />],
["Maps", "green", <Map key="d" />],
["Photos", "orange", <Camera key="e" />],
["Calendar", "red", <Calendar key="f" />],
["Music", "red", <Music key="g" />],
["TV", "graphite", <Film key="h" />],
["Notes", "amber", <Notebook key="i" />],
["Terminal", "graphite", <Terminal key="j" />],
["Calculator", "graphite", <Calculator key="k" />],
["Settings", "graphite", <Settings key="l" />],
]
const apps: LaunchpadApp[] = spec.map(([name, tone, glyph]) => ({
id: name.toLowerCase(),
name,
icon: <AppIcon size={64} tone={tone}>{glyph}</AppIcon>,
}))
export default function LaunchpadDemo() {
const [open, setOpen] = React.useState(false)
const [launched, setLaunched] = React.useState<string | null>(null)
return (
<div className="relative isolate h-[420px] w-full max-w-[720px] overflow-hidden rounded-2xl border border-foreground/10">
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-linear-to-br from-chart-3 via-chart-5 to-chart-4 dark:brightness-[0.55]" />
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-white">
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 items-center rounded-full bg-white/25 px-4 text-sm font-medium backdrop-blur outline-none hover:bg-white/35 focus-visible:ring-[3px] focus-visible:ring-white/70"
>
Open Launchpad
</button>
<p className="text-sm" role="status">{launched ? `Launched ${launched}` : "Pick an app to launch it."}</p>
</div>
<Launchpad
apps={apps}
open={open}
onClose={() => setOpen(false)}
onLaunch={(a) => {
setLaunched(a.name)
setOpen(false)
}}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/launchpadInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/directionCopy the source into your project.
components/ballmac/launchpad.tsx// Ballmac UI: Launchpad. https://ui.ballmac.com/components/launchpad "use client" import * as React from "react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { Search } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { useDirection } from "@/lib/ballmac/direction" type LaunchpadApp = { /** Unique id. */ id: string /** Name under the icon. */ name: string /** The icon, usually an AppIcon. */ icon: React.ReactNode } type LaunchpadProps = Omit<React.ComponentProps<"div">, "children"> & { /** The apps to show, in order. */ apps: LaunchpadApp[] /** Whether the launcher is showing. */ open: boolean /** Called when the launcher asks to close (Escape, or a click on the empty background). */ onClose?: () => void /** Called when an app is chosen. */ onLaunch?: (app: LaunchpadApp) => void /** Width of one app cell in pixels. */ cellWidth?: number /** Height of one app cell in pixels. */ cellHeight?: number /** Accessible name of the launcher. */ label?: string } /** * The macOS Launchpad: a frosted overlay with a search field, pages of apps and page dots. * It fills its positioned parent. Arrow keys move between apps and across pages, PageUp and PageDown turn pages, * and typing filters. Escape closes it. */ function Launchpad({ apps, open, onClose, onLaunch, cellWidth = 120, cellHeight = 112, label, className, ...props }: LaunchpadProps) { const dir = useDirection() const msg = useMessages() label ??= msg("launchpad.label", "Launchpad") const reduce = useReducedMotion() const rootRef = React.useRef<HTMLDivElement>(null) const searchRef = React.useRef<HTMLInputElement>(null) const [size, setSize] = React.useState({ w: 800, h: 560 }) const [query, setQuery] = React.useState("") const [page, setPage] = React.useState(0) const [focus, setFocus] = React.useState(0) const wheel = React.useRef(0) React.useEffect(() => { const el = rootRef.current if (!el || !open) return const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight }) measure() const ro = new ResizeObserver(measure) ro.observe(el) return () => ro.disconnect() }, [open]) React.useEffect(() => { if (open) { setQuery("") setPage(0) setFocus(0) const id = requestAnimationFrame(() => searchRef.current?.focus()) return () => cancelAnimationFrame(id) } }, [open]) const cols = Math.max(2, Math.min(8, Math.floor((size.w - 48) / cellWidth))) const rows = Math.max(1, Math.min(5, Math.floor((size.h - 170) / cellHeight))) const perPage = cols * rows const shown = apps.filter((a) => !query || a.name.toLowerCase().includes(query.toLowerCase())) const pages = Math.max(1, Math.ceil(shown.length / perPage)) const current = Math.min(page, pages - 1) function goPage(next: number) { setPage(Math.min(pages - 1, Math.max(0, next))) } function focusApp(index: number) { const i = Math.min(shown.length - 1, Math.max(0, index)) setFocus(i) setPage(Math.floor(i / perPage)) requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[data-app-index="${i}"]`)?.focus()) } function onKeyDown(e: React.KeyboardEvent) { if (e.key === "Escape") { e.preventDefault() onClose?.() } else if (e.key === "PageDown" || (e.key === (dir === "rtl" ? "ArrowLeft" : "ArrowRight") && e.ctrlKey)) { e.preventDefault() goPage(current + 1) } else if (e.key === "PageUp" || (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft") && e.ctrlKey)) { e.preventDefault() goPage(current - 1) } } function onAppKey(e: React.KeyboardEvent, index: number) { const move: Record<string, number> = { ArrowRight: dir === "rtl" ? -1 : 1, ArrowLeft: dir === "rtl" ? 1 : -1, ArrowDown: cols, ArrowUp: -cols } if (e.key in move && !e.ctrlKey) { e.preventDefault() focusApp(index + move[e.key]!) } else if (e.key === "Home") { e.preventDefault() focusApp(0) } else if (e.key === "End") { e.preventDefault() focusApp(shown.length - 1) } } return ( <AnimatePresence> {open && ( <motion.div ref={rootRef} data-slot="launchpad" role="dialog" aria-label={label} onKeyDown={onKeyDown} onWheel={(e) => { wheel.current += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY if (Math.abs(wheel.current) > 120) { goPage(current + Math.sign(wheel.current)) wheel.current = 0 } }} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.12, filter: "blur(10px)" }} animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, filter: "blur(0px)" }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.12, filter: "blur(10px)" }} transition={{ duration: reduce ? 0.12 : 0.32, ease: [0.22, 1, 0.36, 1] }} 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)} {...(props as object)} onClick={(e) => { if (e.target === e.currentTarget) onClose?.() }} > <div className="relative mt-5 w-56 max-w-[70%] shrink-0"> <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" /> <input ref={searchRef} type="search" value={query} aria-label={msg("launchpad.searchApps", "Search apps")} placeholder={msg("launchpad.search", "Search")} onChange={(e) => { setQuery(e.target.value) setPage(0) setFocus(0) }} onKeyDown={(e) => { if (e.key === "Enter" && shown[0]) onLaunch?.(shown[0]) else if (e.key === "ArrowDown" && shown.length) { e.preventDefault() focusApp(0) } }} 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" /> </div> <div className="relative min-h-0 w-full flex-1 overflow-hidden" onClick={(e) => e.target === e.currentTarget && onClose?.()}> <motion.div className="flex h-full" style={{ width: `${pages * 100}%` }} animate={{ x: `${(-current * 100) / pages}%` }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 30 }} > {Array.from({ length: pages }, (_, p) => ( <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 } : {})}> <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 })}> {shown.slice(p * perPage, (p + 1) * perPage).map((app, i) => { const index = p * perPage + i return ( <li key={app.id} className="flex items-start justify-center"> <button type="button" data-app-index={index} tabIndex={focus === index ? 0 : -1} onFocus={() => setFocus(index)} onKeyDown={(e) => onAppKey(e, index)} onClick={() => onLaunch?.(app)} 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" > <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> <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> </button> </li> ) })} </ul> </div> ))} </motion.div> {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>} </div> <div className="flex h-9 shrink-0 items-center gap-2" role="group" aria-label={msg("launchpad.pages", "Pages")}> {pages > 1 && Array.from({ length: pages }, (_, p) => ( <button key={p} type="button" aria-label={msg("launchpad.page", "Page {n}", { n: p + 1 })} aria-current={p === current ? "true" : undefined} onClick={() => goPage(p)} 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")} /> ))} </div> <p className="sr-only" role="status"> {msg("launchpad.summary", { one: "{count} app, page {page} of {pages}", other: "{count} apps, page {page} of {pages}" }, { count: shown.length, page: current + 1, pages })} </p> </motion.div> )} </AnimatePresence> ) } export { Launchpad, type LaunchpadProps, type LaunchpadApp }Update the import paths to match your project setup.
Usage
import { Launchpad } from "@/components/ballmac/launchpad"The full example is in the Code tab above.
Examples
Many apps and paging
36 apps, page 1 of 2
"use client"
import * as React from "react"
import { AppIcon, type IconTone } from "@/components/ballmac/mac-icons"
import { Launchpad, type LaunchpadApp } from "@/components/ballmac/launchpad"
const tones: IconTone[] = ["blue", "teal", "green", "amber", "orange", "red", "purple", "graphite"]
const names = ["Atlas", "Beacon", "Canvas", "Drift", "Ember", "Fable", "Glide", "Harbor", "Ivy", "Jolt", "Kite", "Lumen", "Mosaic", "Nova", "Orbit", "Pulse", "Quill", "Relay", "Sketch", "Tide", "Unity", "Vale", "Wave", "Xenon", "Yarn", "Zephyr", "Alder", "Birch", "Cedar", "Dune", "Elm", "Fern", "Grove", "Hazel", "Iris", "Juniper"]
const apps: LaunchpadApp[] = names.map((name, i) => ({
id: name.toLowerCase(),
name,
icon: <AppIcon size={60} tone={tones[i % tones.length]}>{name[0]}</AppIcon>,
}))
export default function LaunchpadMany() {
const [open, setOpen] = React.useState(true)
return (
<div className="relative isolate h-[400px] w-full max-w-[720px] overflow-hidden rounded-2xl border border-foreground/10">
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-linear-to-br from-chart-1 to-chart-2 dark:brightness-[0.55]" />
{!open && (
<div className="flex h-full items-center justify-center">
<button type="button" onClick={() => setOpen(true)} className="rounded-full bg-white/25 px-4 py-2 text-sm font-medium text-white outline-none focus-visible:ring-[3px] focus-visible:ring-white/70">
Reopen Launchpad
</button>
</div>
)}
<Launchpad apps={apps} open={open} onClose={() => setOpen(false)} onLaunch={() => setOpen(false)} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
apps*The apps to show, in order. | LaunchpadApp[] | — |
open*Whether the launcher is showing. | boolean | — |
onCloseCalled when the launcher asks to close (Escape, or a click on the empty background). | () => void | — |
onLaunchCalled when an app is chosen. | (app: LaunchpadApp) => void | — |
cellWidthWidth of one app cell in pixels. | number | 120 |
cellHeightHeight of one app cell in pixels. | number | 112 |
labelAccessible name of the launcher. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move between apps across pages |
| PageUp / PageDown | Turn pages |
| Enter in search | Launches the first match |
| Escape | Closes |
| Reduced motion | No zoom or blur on open |
Use with AI
<Launchpad apps={[{ id, name, icon }]} open onClose onLaunch />. It fills its positioned parent. Pages, rows and columns are computed from the available size. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Launchpad (@ballmac/launchpad) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Mac desktop demos
- App launchers in dashboards
Not for
- Command palettes (command)
- Navigation menus
Registry JSON: https://ui.ballmac.com/r/launchpad.json
Credits
Free to use in personal and commercial projects.
Pairs well with
Dock
A macOS-style dock whose icons grow with spring physics as the pointer approaches and push their neighbors apart. Labels, running dots, a launch bounce and arrow-key navigation included.
Mac Icons
Resolution-independent macOS-style icons drawn in SVG and CSS: folders, documents with extension badges, drives and gradient app icons in eight theme-aware colors.
Spotlight Search
A macOS Spotlight-style command palette on cmdk: a large translucent search field, grouped results with app-icon tiles, fuzzy filtering and arrow-key navigation, inline or as a ⌘K dialog.
Control Center
The macOS Control Center panel in frosted glass: toggle tiles with round badges, grouped Wi-Fi, Bluetooth and AirDrop rows, pill sliders with the icon in the track, and a Now Playing tile. Real toggle buttons and Radix sliders.