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.
"use client"
import * as React from "react"
import { Calculator, CalendarDays, FileText, Folder, Moon, Music2, Presentation, SquarePen, SquareTerminal, StickyNote, Table } from "lucide-react"
import {
SpotlightSearch,
SpotlightSearchEmpty,
SpotlightSearchFooter,
SpotlightSearchGroup,
SpotlightSearchInput,
SpotlightSearchItem,
SpotlightSearchList,
} from "@/components/ballmac/spotlight-search"
const app = "text-white shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.25),0_1px_2px_rgb(0_0_0/0.2)]"
export default function SpotlightSearchDemo() {
const [opened, setOpened] = React.useState<string | null>(null)
const open = (name: string) => () => setOpened(name)
return (
<div className="relative isolate flex h-[440px] w-full max-w-[640px] justify-center overflow-hidden rounded-2xl border border-foreground/10 px-3 pt-8 sm:px-8 sm:pt-10">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 dark:brightness-[0.55]">
<div className="absolute inset-0 bg-linear-to-br from-chart-1 via-chart-4 to-chart-5" />
<div className="absolute -inset-x-1/4 top-1/2 h-full rounded-[50%] bg-white/20 blur-2xl" />
</div>
<SpotlightSearch label="Spotlight" className="h-fit max-w-[560px]">
<SpotlightSearchInput autoFocus={false} />
<SpotlightSearchList className="max-h-[300px]">
<SpotlightSearchEmpty>No results</SpotlightSearchEmpty>
<SpotlightSearchGroup heading="Top Hit">
<SpotlightSearchItem icon={<StickyNote />} iconClassName={`${app} bg-linear-to-b from-chart-3/80 to-chart-3`} detail="Application" onSelect={open("Notes")}>
Notes
</SpotlightSearchItem>
</SpotlightSearchGroup>
<SpotlightSearchGroup heading="Applications">
<SpotlightSearchItem icon={<CalendarDays />} iconClassName="bg-white text-destructive" detail="Application" onSelect={open("Calendar")}>
Calendar
</SpotlightSearchItem>
<SpotlightSearchItem icon={<SquareTerminal />} iconClassName={`${app} bg-black/85`} detail="Application" onSelect={open("Terminal")}>
Terminal
</SpotlightSearchItem>
<SpotlightSearchItem icon={<Music2 />} iconClassName={`${app} bg-linear-to-b from-chart-5 to-chart-4`} detail="Application" onSelect={open("Music")}>
Music
</SpotlightSearchItem>
<SpotlightSearchItem icon={<Calculator />} iconClassName={`${app} bg-linear-to-b from-muted-foreground/70 to-muted-foreground`} detail="Application" onSelect={open("Calculator")}>
Calculator
</SpotlightSearchItem>
</SpotlightSearchGroup>
<SpotlightSearchGroup heading="Documents">
<SpotlightSearchItem icon={<Presentation />} iconClassName={`${app} bg-linear-to-b from-chart-1/80 to-chart-1`} detail="Launch · Keynote" onSelect={open("Q4 Launch Plan")}>
Q4 Launch Plan
</SpotlightSearchItem>
<SpotlightSearchItem icon={<Table />} iconClassName={`${app} bg-linear-to-b from-chart-2/80 to-chart-2`} detail="Finance · Sheet" onSelect={open("Pricing Model")}>
Pricing Model
</SpotlightSearchItem>
<SpotlightSearchItem icon={<FileText />} detail="Brand · PDF" onSelect={open("Brand Guidelines")}>
Brand Guidelines
</SpotlightSearchItem>
<SpotlightSearchItem icon={<Folder className="text-chart-1" fill="currentColor" fillOpacity={0.25} />} detail="Folder" onSelect={open("Screenshots")}>
Screenshots
</SpotlightSearchItem>
</SpotlightSearchGroup>
<SpotlightSearchGroup heading="Actions">
<SpotlightSearchItem icon={<SquarePen />} detail="⌘N" onSelect={open("New Note")}>
New Note
</SpotlightSearchItem>
<SpotlightSearchItem icon={<Moon />} detail="⇧⌘D" keywords={["theme", "appearance"]} onSelect={open("Dark Mode")}>
Toggle Dark Mode
</SpotlightSearchItem>
</SpotlightSearchGroup>
</SpotlightSearchList>
<SpotlightSearchFooter>
<span aria-live="polite" className="min-w-0 flex-1 truncate">
{opened ? `Opening ${opened}…` : "Type to search apps, documents and actions"}
</span>
<span className="shrink-0 max-sm:hidden">↵ Open</span>
<span className="shrink-0 max-sm:hidden">⌘↵ Show in Folder</span>
</SpotlightSearchFooter>
</SpotlightSearch>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/spotlight-searchInstall the dependencies.
$ pnpm add cmdk@^1 radix-ui lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/spotlight-search.tsx// Ballmac UI: Spotlight Search. https://ui.ballmac.com/components/spotlight-search "use client" import * as React from "react" import { Command as CommandPrimitive } from "cmdk" import { SearchIcon } from "lucide-react" import { Dialog as DialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type SpotlightSearchProps = React.ComponentProps<typeof CommandPrimitive> /** * cmdk scrolls the selected row (and its group heading) into view with scrollIntoView, which also * scrolls the page when the palette is off-screen, e.g. an inline demo below the fold. Route those * calls to the results list only, so selection never moves the page. */ function scrollWithinList(this: HTMLElement) { const list = this.closest<HTMLElement>("[cmdk-list]") if (!list) return const bounds = list.getBoundingClientRect() const rect = this.getBoundingClientRect() if (rect.top < bounds.top) list.scrollTop -= bounds.top - rect.top + 8 else if (rect.bottom > bounds.bottom) list.scrollTop += rect.bottom - bounds.bottom + 8 } function keepScrollInList(el: HTMLElement | null) { if (el) el.scrollIntoView = scrollWithinList } function setRef<T>(ref: React.Ref<T> | undefined, value: T | null) { if (typeof ref === "function") ref(value) else if (ref) (ref as React.RefObject<T | null>).current = value } /** * A macOS Spotlight-style command palette on cmdk: a large translucent search field over grouped results. * Renders inline; wrap it in SpotlightSearchDialog for a ⌘K overlay. */ function SpotlightSearch({ className, loop = true, ...props }: SpotlightSearchProps) { return ( <CommandPrimitive data-slot="spotlight-search" loop={loop} className={cn( "flex w-full flex-col overflow-hidden rounded-[22px] border border-white/45 bg-background/70 text-foreground", "shadow-[0_0_0_0.5px_rgb(0_0_0/0.14),0_30px_70px_-20px_rgb(0_0_0/0.45)] backdrop-blur-3xl backdrop-saturate-150", "dark:border-white/10 dark:bg-background/60 dark:shadow-[0_0_0_0.5px_rgb(0_0_0/0.7),0_30px_70px_-20px_rgb(0_0_0/0.8)]", className )} {...props} /> ) } type SpotlightSearchInputProps = React.ComponentProps<typeof CommandPrimitive.Input> & { /** Content at the trailing end of the field, e.g. a Kbd hint or the top hit's kind. */ trailing?: React.ReactNode } function SpotlightSearchInput({ className, placeholder, trailing, ...props }: SpotlightSearchInputProps) { const msg = useMessages() placeholder ??= msg("spotlight-search.placeholder", "Spotlight Search") return ( <div data-slot="spotlight-search-field" className="flex h-14 shrink-0 items-center gap-3 px-4"> <SearchIcon className="size-5 shrink-0 text-foreground/50" aria-hidden="true" /> <CommandPrimitive.Input data-slot="spotlight-search-input" placeholder={placeholder} className={cn( "h-full min-w-0 flex-1 bg-transparent text-xl font-normal tracking-tight text-foreground outline-none placeholder:text-foreground/35 disabled:cursor-not-allowed disabled:opacity-50", className )} {...props} /> {trailing ? <div className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">{trailing}</div> : null} </div> ) } type SpotlightSearchListProps = React.ComponentProps<typeof CommandPrimitive.List> function SpotlightSearchList({ className, ...props }: SpotlightSearchListProps) { return ( <CommandPrimitive.List data-slot="spotlight-search-list" className={cn( "max-h-[min(24rem,60dvh)] scroll-py-2 overflow-x-hidden overflow-y-auto overscroll-contain border-t border-foreground/[0.08] p-2 empty:hidden", className )} {...props} /> ) } function SpotlightSearchEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) { return ( <CommandPrimitive.Empty data-slot="spotlight-search-empty" className={cn("py-8 text-center text-sm text-foreground/50", className)} {...props} /> ) } function SpotlightSearchGroup({ ref, className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) { return ( <CommandPrimitive.Group ref={(el: HTMLDivElement | null) => { keepScrollInList(el?.querySelector<HTMLElement>("[cmdk-group-heading]") ?? null) setRef(ref, el) }} data-slot="spotlight-search-group" className={cn( "[&:not(:first-child)]:mt-1.5 [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:pt-1 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-foreground/45", className )} {...props} /> ) } function SpotlightSearchSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) { return <CommandPrimitive.Separator data-slot="spotlight-search-separator" className={cn("mx-2.5 my-1.5 h-px bg-foreground/[0.08]", className)} {...props} /> } type SpotlightSearchItemProps = React.ComponentProps<typeof CommandPrimitive.Item> & { /** Icon, drawn in a small rounded tile. */ icon?: React.ReactNode /** Classes for the icon tile (a gradient or color, like an app icon). */ iconClassName?: string /** Secondary text at the trailing end: a kind ("Application"), a path, or a shortcut. */ detail?: React.ReactNode } function SpotlightSearchItem({ ref, icon, iconClassName, detail, value, className, children, ...props }: SpotlightSearchItemProps) { return ( <CommandPrimitive.Item ref={(el: HTMLDivElement | null) => { keepScrollInList(el) setRef(ref, el) }} data-slot="spotlight-search-item" // Match on the title only, not the detail text ("Application" would match almost any query). value={value ?? (typeof children === "string" ? children : undefined)} className={cn( "group/spotlight-item relative flex h-10 cursor-default items-center gap-3 rounded-[10px] px-2.5 text-sm outline-none select-none", "data-[selected=true]:bg-[color-mix(in_oklch,var(--ring),black_28%)] data-[selected=true]:text-white data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50", className )} {...props} > {icon ? ( <span data-slot="spotlight-search-item-icon" className={cn( "flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-foreground/[0.07] text-foreground/75 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.2)] [&_svg:not([class*='size-'])]:size-4", !iconClassName && "group-data-[selected=true]/spotlight-item:bg-white/20 group-data-[selected=true]/spotlight-item:text-white", iconClassName )} > {icon} </span> ) : null} <span className="min-w-0 flex-1 truncate">{children}</span> {detail ? ( <span data-slot="spotlight-search-item-detail" className="shrink-0 text-xs text-muted-foreground group-data-[selected=true]/spotlight-item:text-white/90"> {detail} </span> ) : null} </CommandPrimitive.Item> ) } type SpotlightSearchFooterProps = React.ComponentProps<"div"> /** A hint row under the results, e.g. "↵ Open · ⌘↵ Show in Folder". */ function SpotlightSearchFooter({ className, ...props }: SpotlightSearchFooterProps) { return ( <div data-slot="spotlight-search-footer" className={cn("flex h-9 shrink-0 items-center gap-3 border-t border-foreground/[0.08] px-4 text-xs text-foreground/50", className)} {...props} /> ) } /** * Calls `onTrigger` when ⌘ + key (Ctrl + key on Windows and Linux) is pressed anywhere on the page. * Pass `key = false` to turn it off. */ function useSpotlightHotkey(onTrigger: () => void, key: string | false = "k") { const callback = React.useRef(onTrigger) React.useEffect(() => { callback.current = onTrigger }) React.useEffect(() => { if (!key) return const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === key.toLowerCase()) { event.preventDefault() callback.current() } } document.addEventListener("keydown", onKeyDown) return () => document.removeEventListener("keydown", onKeyDown) }, [key]) } type SpotlightSearchDialogProps = Omit<SpotlightSearchProps, "title"> & { /** Controlled open state. */ open?: boolean /** Initial open state when uncontrolled. */ defaultOpen?: boolean /** Called when the dialog opens or closes. */ onOpenChange?: (open: boolean) => void /** Key that toggles the dialog together with ⌘ (or Ctrl). false turns the shortcut off. */ hotkey?: string | false /** Accessible dialog title (visually hidden). */ title?: string } /** SpotlightSearch in a Radix dialog near the top of the screen, toggled with ⌘K by default. Escape closes it. */ function SpotlightSearchDialog({ open: openProp, defaultOpen = false, onOpenChange, hotkey = "k", title, className, children, ...props }: SpotlightSearchDialogProps) { const msg = useMessages() title ??= msg("spotlight-search.title", "Spotlight Search") const [uncontrolled, setUncontrolled] = React.useState(defaultOpen) const open = openProp ?? uncontrolled const setOpen = (next: boolean) => { if (openProp === undefined) setUncontrolled(next) onOpenChange?.(next) } useSpotlightHotkey(() => setOpen(!open), hotkey) return ( <DialogPrimitive.Root open={open} onOpenChange={setOpen}> <DialogPrimitive.Portal> <DialogPrimitive.Overlay data-slot="spotlight-search-overlay" className="fixed inset-0 z-50 bg-black/10 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 motion-reduce:animate-none dark:bg-black/30" /> <DialogPrimitive.Content data-slot="spotlight-search-dialog" aria-describedby={undefined} className="fixed top-[16dvh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-[640px] -translate-x-1/2 outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.97] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-[0.97] duration-150 motion-reduce:animate-none" > <DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title> <SpotlightSearch className={className} {...props}> {children} </SpotlightSearch> </DialogPrimitive.Content> </DialogPrimitive.Portal> </DialogPrimitive.Root> ) } export { SpotlightSearch, SpotlightSearchDialog, SpotlightSearchEmpty, SpotlightSearchFooter, SpotlightSearchGroup, SpotlightSearchInput, SpotlightSearchItem, SpotlightSearchList, SpotlightSearchSeparator, useSpotlightHotkey, type SpotlightSearchDialogProps, type SpotlightSearchFooterProps, type SpotlightSearchInputProps, type SpotlightSearchItemProps, type SpotlightSearchListProps, type SpotlightSearchProps, }Update the import paths to match your project setup.
Usage
import { SpotlightSearch, SpotlightSearchDialog, SpotlightSearchEmpty, SpotlightSearchFooter, SpotlightSearchGroup, SpotlightSearchInput, SpotlightSearchItem, SpotlightSearchList, SpotlightSearchSeparator, useSpotlightHotkey } from "@/components/ballmac/spotlight-search"The full example is in the Code tab above.
Examples
⌘K dialog
"use client"
import * as React from "react"
import { CalendarDays, Search, Settings, SquarePen, SquareTerminal, User } from "lucide-react"
import {
SpotlightSearchDialog,
SpotlightSearchEmpty,
SpotlightSearchGroup,
SpotlightSearchInput,
SpotlightSearchItem,
SpotlightSearchList,
} from "@/components/ballmac/spotlight-search"
export default function SpotlightSearchDialogExample() {
const [open, setOpen] = React.useState(false)
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-64 items-center gap-2 rounded-full border bg-background px-3.5 text-sm text-muted-foreground shadow-xs outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Search className="size-4" aria-hidden="true" />
<span className="flex-1 text-start">Search…</span>
<kbd className="font-sans text-xs">⌘K</kbd>
</button>
<SpotlightSearchDialog open={open} onOpenChange={setOpen}>
<SpotlightSearchInput placeholder="Search or jump to…" />
<SpotlightSearchList>
<SpotlightSearchEmpty>No results</SpotlightSearchEmpty>
<SpotlightSearchGroup heading="Go to">
<SpotlightSearchItem icon={<CalendarDays />} detail="G then C" onSelect={() => setOpen(false)}>
Calendar
</SpotlightSearchItem>
<SpotlightSearchItem icon={<User />} detail="G then P" onSelect={() => setOpen(false)}>
Profile
</SpotlightSearchItem>
<SpotlightSearchItem icon={<Settings />} detail="⌘," onSelect={() => setOpen(false)}>
Settings
</SpotlightSearchItem>
</SpotlightSearchGroup>
<SpotlightSearchGroup heading="Actions">
<SpotlightSearchItem icon={<SquarePen />} detail="⌘N" onSelect={() => setOpen(false)}>
New document
</SpotlightSearchItem>
<SpotlightSearchItem icon={<SquareTerminal />} detail="⌃`" onSelect={() => setOpen(false)}>
Open terminal
</SpotlightSearchItem>
</SpotlightSearchGroup>
</SpotlightSearchList>
</SpotlightSearchDialog>
</>
)
}API reference
<SpotlightSearchInput>
| Prop | Type | Default |
|---|---|---|
trailingContent at the trailing end of the field, e.g. a Kbd hint or the top hit's kind. | React.ReactNode | — |
<SpotlightSearchItem>
| Prop | Type | Default |
|---|---|---|
iconIcon, drawn in a small rounded tile. | React.ReactNode | — |
iconClassNameClasses for the icon tile (a gradient or color, like an app icon). | string | — |
detailSecondary text at the trailing end: a kind ("Application"), a path, or a shortcut. | React.ReactNode | — |
<SpotlightSearchDialog>
| Prop | Type | Default |
|---|---|---|
openControlled open state. | boolean | — |
defaultOpenInitial open state when uncontrolled. | boolean | false |
onOpenChangeCalled when the dialog opens or closes. | (open: boolean) => void | — |
hotkeyKey that toggles the dialog together with ⌘ (or Ctrl). false turns the shortcut off. | string | false | "k" |
titleAccessible dialog title (visually hidden). | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ⌘K / Ctrl+K | Opens and closes SpotlightSearchDialog (hotkey prop; false disables) |
| ArrowUp / ArrowDown | Moves the highlighted result, wrapping at the ends |
| Enter | Runs the highlighted item's onSelect |
| Escape | Closes the dialog and returns focus to where it was |
Use with AI
Inline: <SpotlightSearch><SpotlightSearchInput /><SpotlightSearchList><SpotlightSearchEmpty/><SpotlightSearchGroup heading><SpotlightSearchItem icon iconClassName detail onSelect>Title</SpotlightSearchItem></SpotlightSearchGroup></SpotlightSearchList></SpotlightSearch>. Overlay: swap the root for <SpotlightSearchDialog open onOpenChange hotkey="k">. Root props are cmdk Command props (filter, shouldFilter, value). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Spotlight Search (@ballmac/spotlight-search) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A ⌘K command palette for navigation and actions in an app or docs site
- A Mac-style launcher in a product hero or desktop mockup
- Searching a mixed list of apps, documents and commands with keyboard-first navigation
Not for
- A plain search field for a results page (use input)
- Choosing one value in a form (use select or a combobox)
Registry JSON: https://ui.ballmac.com/r/spotlight-search.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Menu Bar
A translucent macOS menu bar on Radix Menubar: a bold app menu, menus with key equivalents, submenus and checkmarks, a status area with icon buttons and a hydration-safe live clock.
Mac Window
macOS window chrome: traffic lights whose glyphs appear on hover, a centered title or unified toolbar, an optional translucent sidebar with vibrancy, and active and inactive appearances.
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.
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.