Web InnoventixFreeCode

Command Palette Modal

Original · free

command palette modal with search

byWeb InnoventixReact + Tailwind
modalcommandpalettemodals
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/modal-command-palette.json
modal-command-palette.tsx
"use client"

import {
  useState,
  useRef,
  useEffect,
  useMemo,
  useCallback,
  useId,
  type ReactNode,
  type KeyboardEvent,
} from "react"
import { motion, AnimatePresence, useReducedMotion } from "motion/react"

/* ------------------------------------------------------------------ */
/*  Inline SVG icons (stroke = currentColor, no icon library)          */
/* ------------------------------------------------------------------ */

type IconProps = { className?: string }

function SearchIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="11" cy="11" r="7" />
      <path d="m20 20-3.2-3.2" />
    </svg>
  )
}
function GridIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" />
      <rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" />
    </svg>
  )
}
function ChartIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M4 20h16" /><path d="M7 20v-6" /><path d="M12 20V8" /><path d="M17 20v-9" />
    </svg>
  )
}
function UsersIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="9" cy="8" r="3.2" /><path d="M3.5 19a5.5 5.5 0 0 1 11 0" /><path d="M16 6.2a3 3 0 0 1 0 5.6" /><path d="M18 14.5a5.3 5.3 0 0 1 2.5 4.5" />
    </svg>
  )
}
function CardIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="5" width="18" height="14" rx="2.5" /><path d="M3 10h18" /><path d="M7 15h4" />
    </svg>
  )
}
function PlusIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M12 5v14" /><path d="M5 12h14" />
    </svg>
  )
}
function RocketIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M5 15c-1.2 1.4-1.6 4-1.6 4s2.6-.4 4-1.6" /><path d="M9 15l-.9-.9a2 2 0 0 1 0-2.8l4-4A7 7 0 0 1 20 4a7 7 0 0 1-3.3 7.9l-4 4a2 2 0 0 1-2.8 0Z" /><circle cx="14.5" cy="9.5" r="1.4" />
    </svg>
  )
}
function KeyIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="8" cy="15" r="4" /><path d="m11 12 8-8" /><path d="m17 6 2 2" /><path d="m14 9 2 2" />
    </svg>
  )
}
function MailIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="5" width="18" height="14" rx="2.5" /><path d="m4 7 8 6 8-6" />
    </svg>
  )
}
function SwapIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M17 3v14" /><path d="m21 13-4 4-4-4" /><path d="M7 21V7" /><path d="m3 11 4-4 4 4" />
    </svg>
  )
}
function LinkIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M9.5 14.5 14.5 9.5" /><path d="M8 12 6 14a3.5 3.5 0 0 0 5 5l2-2" /><path d="M16 12l2-2a3.5 3.5 0 0 0-5-5l-2 2" />
    </svg>
  )
}
function LogoutIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3" /><path d="M10 12H3" /><path d="m6 8-4 4 4 4" />
    </svg>
  )
}
function BookIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M4 5a2 2 0 0 1 2-2h12v16H6a2 2 0 0 0-2 2Z" /><path d="M18 3v18" /><path d="M8 7h6" /><path d="M8 11h6" />
    </svg>
  )
}
function KeyboardIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="2.5" y="6" width="19" height="12" rx="2.5" /><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M8 14h8" />
    </svg>
  )
}
function LifeBuoyIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><path d="m5.6 5.6 3.2 3.2M15.2 15.2l3.2 3.2M18.4 5.6l-3.2 3.2M8.8 15.2l-3.2 3.2" />
    </svg>
  )
}
function CloseIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m6 6 12 12M18 6 6 18" />
    </svg>
  )
}
function EnterIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M20 6v5a3 3 0 0 1-3 3H5" /><path d="m8 11-3 3 3 3" />
    </svg>
  )
}

/* ------------------------------------------------------------------ */
/*  Command data                                                       */
/* ------------------------------------------------------------------ */

type Command = {
  id: string
  label: string
  hint: string
  section: string
  keywords: string[]
  shortcut?: string[]
  icon: ReactNode
}

const COMMANDS: Command[] = [
  { id: "nav-overview", label: "Go to Overview", hint: "Project health at a glance", section: "Navigation", keywords: ["home", "dashboard", "start"], shortcut: ["G", "O"], icon: <GridIcon className="h-4 w-4" /> },
  { id: "nav-analytics", label: "Go to Analytics", hint: "Traffic, funnels & retention", section: "Navigation", keywords: ["stats", "metrics", "reports", "charts"], shortcut: ["G", "A"], icon: <ChartIcon className="h-4 w-4" /> },
  { id: "nav-team", label: "Go to Team Members", hint: "Roles, seats & permissions", section: "Navigation", keywords: ["people", "users", "members", "roles"], shortcut: ["G", "T"], icon: <UsersIcon className="h-4 w-4" /> },
  { id: "nav-billing", label: "Go to Billing & Plans", hint: "Invoices, usage & upgrades", section: "Navigation", keywords: ["payments", "invoice", "subscription", "plan"], shortcut: ["G", "B"], icon: <CardIcon className="h-4 w-4" /> },

  { id: "new-project", label: "Create New Project", hint: "Spin up a fresh workspace", section: "Create", keywords: ["add", "start", "workspace"], shortcut: ["N", "P"], icon: <PlusIcon className="h-4 w-4" /> },
  { id: "new-deploy", label: "Trigger Deployment", hint: "Ship the current branch to production", section: "Create", keywords: ["ship", "release", "publish", "build"], shortcut: ["N", "D"], icon: <RocketIcon className="h-4 w-4" /> },
  { id: "new-invite", label: "Invite Teammate", hint: "Send an email invitation", section: "Create", keywords: ["add member", "share", "collaborator"], shortcut: ["N", "I"], icon: <MailIcon className="h-4 w-4" /> },
  { id: "new-key", label: "Generate API Key", hint: "Create a scoped access token", section: "Create", keywords: ["token", "secret", "credential", "auth"], shortcut: ["N", "K"], icon: <KeyIcon className="h-4 w-4" /> },

  { id: "acc-workspace", label: "Switch Workspace", hint: "Jump between organizations", section: "Account", keywords: ["org", "change", "team", "tenant"], shortcut: ["⌘", "\\"], icon: <SwapIcon className="h-4 w-4" /> },
  { id: "acc-copy", label: "Copy Current URL", hint: "Grab a shareable link", section: "Account", keywords: ["link", "share", "clipboard"], shortcut: ["⌘", "C"], icon: <LinkIcon className="h-4 w-4" /> },
  { id: "acc-signout", label: "Sign Out", hint: "End this session", section: "Account", keywords: ["logout", "exit", "leave"], icon: <LogoutIcon className="h-4 w-4" /> },

  { id: "help-docs", label: "Read Documentation", hint: "Guides, API reference & recipes", section: "Help", keywords: ["docs", "manual", "learn", "guide"], shortcut: ["?"], icon: <BookIcon className="h-4 w-4" /> },
  { id: "help-shortcuts", label: "Keyboard Shortcuts", hint: "Browse every hotkey", section: "Help", keywords: ["hotkeys", "keys", "bindings"], shortcut: ["⌘", "/"], icon: <KeyboardIcon className="h-4 w-4" /> },
  { id: "help-support", label: "Contact Support", hint: "Chat with a human in minutes", section: "Help", keywords: ["help", "assist", "ticket", "email"], icon: <LifeBuoyIcon className="h-4 w-4" /> },
]

/* ------------------------------------------------------------------ */
/*  Match highlighting                                                 */
/* ------------------------------------------------------------------ */

function highlight(text: string, query: string): ReactNode {
  const q = query.trim()
  if (!q) return text
  const idx = text.toLowerCase().indexOf(q.toLowerCase())
  if (idx === -1) return text
  return (
    <>
      {text.slice(0, idx)}
      <mark className="rounded-[3px] bg-amber-200/80 px-0.5 text-inherit dark:bg-amber-400/25">
        {text.slice(idx, idx + q.length)}
      </mark>
      {text.slice(idx + q.length)}
    </>
  )
}

function Kbd({ children }: { children: ReactNode }) {
  return (
    <kbd className="inline-flex min-w-[1.4rem] items-center justify-center rounded-md border border-slate-300/80 bg-slate-100 px-1.5 py-0.5 font-sans text-[11px] font-medium text-slate-600 shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
      {children}
    </kbd>
  )
}

/* ------------------------------------------------------------------ */
/*  Component                                                          */
/* ------------------------------------------------------------------ */

export default function CommandPalette() {
  const reduce = useReducedMotion()
  const uid = useId().replace(/[:]/g, "")
  const listId = `${uid}-list`
  const inputId = `${uid}-input`

  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState("")
  const [activeIndex, setActiveIndex] = useState(0)
  const [toast, setToast] = useState<string | null>(null)

  const inputRef = useRef<HTMLInputElement>(null)
  const panelRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null)

  const filtered = useMemo<Command[]>(() => {
    const q = query.trim().toLowerCase()
    if (!q) return COMMANDS
    return COMMANDS.filter((c) => {
      const hay = `${c.label} ${c.hint} ${c.keywords.join(" ")}`.toLowerCase()
      return hay.includes(q)
    })
  }, [query])

  const grouped = useMemo(() => {
    const out: { section: string; items: { cmd: Command; index: number }[] }[] = []
    filtered.forEach((cmd, index) => {
      let group = out.find((g) => g.section === cmd.section)
      if (!group) {
        group = { section: cmd.section, items: [] }
        out.push(group)
      }
      group.items.push({ cmd, index })
    })
    return out
  }, [filtered])

  const optionId = useCallback((i: number) => `${uid}-opt-${i}`, [uid])

  const runCommand = useCallback((cmd: Command) => {
    setOpen(false)
    setToast(cmd.label)
    if (toastTimer.current) clearTimeout(toastTimer.current)
    toastTimer.current = setTimeout(() => setToast(null), 2600)
  }, [])

  // Global open shortcut (Cmd/Ctrl + K)
  useEffect(() => {
    const onKey = (e: globalThis.KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault()
        setOpen((v) => !v)
      }
    }
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [])

  // Reset + focus + scroll lock + focus restore
  useEffect(() => {
    if (open) {
      setQuery("")
      setActiveIndex(0)
      const prevOverflow = document.body.style.overflow
      document.body.style.overflow = "hidden"
      const t = setTimeout(() => inputRef.current?.focus(), 0)
      return () => {
        clearTimeout(t)
        document.body.style.overflow = prevOverflow
        triggerRef.current?.focus()
      }
    }
  }, [open])

  // Keep active option in view
  useEffect(() => {
    if (!open) return
    const el = document.getElementById(optionId(activeIndex))
    el?.scrollIntoView({ block: "nearest" })
  }, [activeIndex, open, optionId, filtered.length])

  useEffect(() => {
    return () => {
      if (toastTimer.current) clearTimeout(toastTimer.current)
    }
  }, [])

  const move = useCallback(
    (delta: number) => {
      const len = filtered.length
      if (len === 0) return
      setActiveIndex((i) => (i + delta + len) % len)
    },
    [filtered.length]
  )

  const onInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "ArrowDown") {
      e.preventDefault()
      move(1)
    } else if (e.key === "ArrowUp") {
      e.preventDefault()
      move(-1)
    } else if (e.key === "Home") {
      e.preventDefault()
      setActiveIndex(0)
    } else if (e.key === "End") {
      e.preventDefault()
      if (filtered.length) setActiveIndex(filtered.length - 1)
    } else if (e.key === "Enter") {
      e.preventDefault()
      const cmd = filtered[activeIndex]
      if (cmd) runCommand(cmd)
    }
  }

  const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.preventDefault()
      setOpen(false)
      return
    }
    if (e.key === "Tab") {
      const root = panelRef.current
      if (!root) return
      const focusables = Array.from(
        root.querySelectorAll<HTMLElement>(
          'a[href],button:not([disabled]),input,[tabindex]:not([tabindex="-1"])'
        )
      ).filter((el) => el.offsetParent !== null || el === document.activeElement)
      if (focusables.length === 0) return
      const first = focusables[0]
      const last = focusables[focusables.length - 1]
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first.focus()
      }
    }
  }

  const activeDescendant = filtered.length ? optionId(activeIndex) : undefined

  return (
    <section className="cmdpal-scope relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-slate-100 to-slate-200 px-6 py-20 text-slate-900 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 dark:text-slate-100 sm:px-8 sm:py-28">
      <style>{`
        @keyframes cmdpal-fade-up {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes cmdpal-ping {
          0% { transform: scale(1); opacity: .55; }
          70%, 100% { transform: scale(2.2); opacity: 0; }
        }
        @keyframes cmdpal-grid-drift {
          from { background-position: 0 0; }
          to { background-position: 44px 44px; }
        }
        @media (prefers-reduced-motion: reduce) {
          .cmdpal-scope *, .cmdpal-scope *::before, .cmdpal-scope *::after {
            animation-duration: .001ms !important;
            animation-iteration-count: 1 !important;
            transition-duration: .001ms !important;
          }
        }
      `}</style>

      {/* Ambient backdrop grid */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.5] [animation:cmdpal-grid-drift_22s_linear_infinite] [background-image:linear-gradient(to_right,rgba(100,116,139,0.09)_1px,transparent_1px),linear-gradient(to_bottom,rgba(100,116,139,0.09)_1px,transparent_1px)] [background-size:44px_44px] dark:opacity-40"
      />
      <div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-0 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-400/20 blur-[90px] dark:bg-indigo-500/20" />

      <div className="relative mx-auto max-w-2xl text-center">
        <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-wider text-slate-500 backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/60 dark:text-slate-400">
          <span className="relative flex h-1.5 w-1.5">
            <span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500 [animation:cmdpal-ping_2s_cubic-bezier(0,0,0.2,1)_infinite]" />
            <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
          </span>
          Nebula Console
        </span>

        <h2 className="mt-6 text-balance text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
          Everything, one keystroke away
        </h2>
        <p className="mx-auto mt-3 max-w-md text-pretty text-sm leading-relaxed text-slate-600 dark:text-slate-400 sm:text-base">
          Navigate, create, and ship without lifting your hands from the keyboard. Open the command palette and just start typing.
        </p>

        <button
          ref={triggerRef}
          type="button"
          onClick={() => setOpen(true)}
          aria-haspopup="dialog"
          aria-expanded={open}
          className="group mx-auto mt-8 flex w-full max-w-md items-center gap-3 rounded-xl border border-slate-300/80 bg-white/80 px-4 py-3 text-left shadow-sm backdrop-blur transition hover:border-indigo-400/70 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:border-slate-700/80 dark:bg-slate-900/70 dark:hover:border-indigo-500/70 dark:focus-visible:ring-offset-slate-950"
        >
          <SearchIcon className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-indigo-500" />
          <span className="flex-1 text-sm text-slate-500 dark:text-slate-400">Search commands…</span>
          <span className="flex items-center gap-1">
            <Kbd>⌘</Kbd>
            <Kbd>K</Kbd>
          </span>
        </button>

        {/* Live region + toast */}
        <div aria-live="polite" className="sr-only">
          {toast ? `Ran command: ${toast}` : ""}
        </div>
      </div>

      <AnimatePresence>
        {toast && (
          <motion.div
            key="toast"
            initial={{ opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 12 }}
            transition={{ duration: reduce ? 0.001 : 0.22 }}
            className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2"
          >
            <div className="flex items-center gap-2.5 rounded-xl border border-slate-800/10 bg-slate-900 px-4 py-2.5 text-sm text-white shadow-xl dark:border-white/10 dark:bg-white dark:text-slate-900">
              <EnterIcon className="h-4 w-4 text-emerald-400 dark:text-emerald-600" />
              <span className="font-medium">{toast}</span>
              <span className="text-slate-400 dark:text-slate-500">executed</span>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Modal */}
      <AnimatePresence>
        {open && (
          <motion.div
            key="overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0.001 : 0.18 }}
            className="fixed inset-0 z-40 flex items-start justify-center bg-slate-950/50 px-4 pt-[12vh] backdrop-blur-sm"
            onMouseDown={(e) => {
              if (e.target === e.currentTarget) setOpen(false)
            }}
          >
            <motion.div
              ref={panelRef}
              role="dialog"
              aria-modal="true"
              aria-label="Command palette"
              onKeyDown={onPanelKeyDown}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -10, scale: 0.98 }}
              animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
              transition={reduce ? { duration: 0.001 } : { type: "spring", stiffness: 420, damping: 32 }}
              className="w-full max-w-xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl ring-1 ring-black/5 dark:border-slate-800 dark:bg-slate-900 dark:ring-white/5"
            >
              {/* Search row */}
              <div className="flex items-center gap-3 border-b border-slate-200 px-4 dark:border-slate-800">
                <SearchIcon className="h-5 w-5 shrink-0 text-slate-400" />
                <input
                  ref={inputRef}
                  id={inputId}
                  type="text"
                  role="combobox"
                  aria-expanded="true"
                  aria-controls={listId}
                  aria-autocomplete="list"
                  aria-activedescendant={activeDescendant}
                  aria-label="Search commands"
                  value={query}
                  onChange={(e) => {
                    setQuery(e.target.value)
                    setActiveIndex(0)
                  }}
                  onKeyDown={onInputKeyDown}
                  placeholder="Type a command or search…"
                  autoComplete="off"
                  spellCheck={false}
                  className="w-full bg-transparent py-4 text-[15px] text-slate-900 outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500"
                />
                <button
                  type="button"
                  onClick={() => setOpen(false)}
                  aria-label="Close command palette"
                  className="shrink-0 rounded-md p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                >
                  <CloseIcon className="h-4 w-4" />
                </button>
              </div>

              {/* Results */}
              <div
                id={listId}
                role="listbox"
                aria-label="Commands"
                className="max-h-[min(56vh,26rem)] overflow-y-auto overscroll-contain p-2"
              >
                {filtered.length === 0 ? (
                  <div className="flex flex-col items-center justify-center gap-2 px-6 py-14 text-center [animation:cmdpal-fade-up_.3s_ease]">
                    <div className="flex h-11 w-11 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-slate-800">
                      <SearchIcon className="h-5 w-5" />
                    </div>
                    <p className="text-sm font-medium text-slate-700 dark:text-slate-200">No matching commands</p>
                    <p className="max-w-[16rem] text-xs text-slate-500 dark:text-slate-400">
                      Nothing found for “<span className="font-medium text-slate-700 dark:text-slate-300">{query}</span>”. Try another keyword.
                    </p>
                  </div>
                ) : (
                  grouped.map((group) => (
                    <div key={group.section} role="group" aria-label={group.section} className="mb-1 last:mb-0">
                      <div className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                        {group.section}
                      </div>
                      {group.items.map(({ cmd, index }) => {
                        const active = index === activeIndex
                        return (
                          <div
                            key={cmd.id}
                            id={optionId(index)}
                            role="option"
                            aria-selected={active}
                            tabIndex={-1}
                            onMouseMove={() => setActiveIndex(index)}
                            onClick={() => runCommand(cmd)}
                            className={[
                              "flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
                              active
                                ? "bg-indigo-50 text-indigo-950 dark:bg-indigo-500/15 dark:text-indigo-100"
                                : "text-slate-700 dark:text-slate-200",
                            ].join(" ")}
                          >
                            <span
                              className={[
                                "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors",
                                active
                                  ? "border-indigo-300 bg-white text-indigo-600 dark:border-indigo-400/40 dark:bg-indigo-500/20 dark:text-indigo-200"
                                  : "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400",
                              ].join(" ")}
                            >
                              {cmd.icon}
                            </span>
                            <span className="flex min-w-0 flex-col">
                              <span className="truncate font-medium">{highlight(cmd.label, query)}</span>
                              <span className="truncate text-xs text-slate-500 dark:text-slate-400">
                                {highlight(cmd.hint, query)}
                              </span>
                            </span>
                            {cmd.shortcut && (
                              <span className="ml-auto flex shrink-0 items-center gap-1">
                                {cmd.shortcut.map((k, i) => (
                                  <Kbd key={i}>{k}</Kbd>
                                ))}
                              </span>
                            )}
                            {active && (
                              <span className="ml-1 shrink-0 text-indigo-400 dark:text-indigo-300" aria-hidden="true">
                                <EnterIcon className="h-4 w-4" />
                              </span>
                            )}
                          </div>
                        )
                      })}
                    </div>
                  ))
                )}
              </div>

              {/* Footer */}
              <div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50/80 px-4 py-2.5 text-[11px] text-slate-500 dark:border-slate-800 dark:bg-slate-950/40 dark:text-slate-400">
                <span className="flex items-center gap-3">
                  <span className="flex items-center gap-1.5">
                    <Kbd>↑</Kbd>
                    <Kbd>↓</Kbd>
                    <span>navigate</span>
                  </span>
                  <span className="flex items-center gap-1.5">
                    <Kbd>↵</Kbd>
                    <span>select</span>
                  </span>
                  <span className="hidden items-center gap-1.5 sm:flex">
                    <Kbd>esc</Kbd>
                    <span>close</span>
                  </span>
                </span>
                <span className="tabular-nums">
                  {filtered.length} {filtered.length === 1 ? "result" : "results"}
                </span>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </section>
  )
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →