EmpireUI
Get Pro
← Blog9 min read#glassmorphism#command palette#react

Glassmorphism Command Palette: ⌘K Frosted Search Modal in React

Build a ⌘K command palette with glassmorphism in React — frosted backdrop, keyboard nav, fuzzy search, and zero runtime deps beyond React 18.

frosted glass search modal floating over a dark gradient desktop interface

Why a Command Palette Sells the Frosted-Glass Effect

Command palettes live in the spotlight — literally. They appear over your existing UI, demand attention for a second, then disappear. That ephemeral, floating quality is exactly what glassmorphism components were designed for. A dark-background command palette with backdrop-filter: blur(20px) and a translucent panel doesn't just look good; it contextually communicates "I'm above everything else."

The trend picked up serious traction around 2021 when Linear, Raycast, and Vercel all shipped ⌘K interfaces with frosted panels. By 2026 it's basically a design pattern you're expected to have if your app has more than a handful of routes. Honestly, if you still rely on a hamburger menu alone, you're leaving a ton of UX on the table.

What makes the glassmorphism treatment work here is contrast. The blur pulls the background visually backward, while your command list stays sharp at 1px border and 100% opacity. You get depth without shadows that feel heavy. One more thing — the frosted panel also signals "keyboard-first" to power users before they've even typed a character.

Setting Up the Modal Shell

You don't need cmdk, Radix, or any headless library for a solid command palette. React 18's useId, useRef, and a <dialog> element get you 90% there. Start with the portal so the modal renders outside your app's stacking context:

// CommandPalette.tsx
import { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface CommandPaletteProps {
  open: boolean;
  onClose: () => void;
  children: ReactNode;
}

export function CommandPalette({ open, onClose, children }: CommandPaletteProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const el = dialogRef.current;
    if (!el) return;
    if (open) {
      el.showModal();
    } else {
      el.close();
    }
  }, [open]);

  // Close on backdrop click
  const handleClick = (e: React.MouseEvent<HTMLDialogElement>) => {
    if (e.target === dialogRef.current) onClose();
  };

  return createPortal(
    <dialog
      ref={dialogRef}
      onClick={handleClick}
      className="cp-dialog"
    >
      <div className="cp-panel">{children}</div>
    </dialog>,
    document.body
  );
}

Using the native <dialog> element buys you focus trapping, Escape to close, and ::backdrop pseudo-element for free. No aria-modal gymnastics needed — the browser handles it. That said, you'll want to polyfill for Safari 15.3 and below if your analytics show that audience.

Worth noting: showModal() automatically places the dialog in the top layer, which means it renders above fixed headers, sticky sidebars, and z-index: 9999 offenders. You can stop worrying about stacking context wars.

The CSS: Getting the Frosted Look Right

The glassmorphism effect lives in about 8 lines of CSS. The hard part is tuning the values so it reads clearly against both light and dark backgrounds your users might have behind the palette.

/* globals.css or your CSS module */

.cp-dialog {
  /* Reset browser dialog defaults */
  padding: 0;
  border: none;
  background: transparent;
  max-width: 640px;
  width: 90vw;
  border-radius: 16px;
  /* Center on screen */
  position: fixed;
  top: 20%;
  left: 50%;
  translate: -50% 0;
}

.cp-dialog::backdrop {
  background: rgba(0, 0, 0, 0.45);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}

.cp-panel {
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(24px) saturate(180%);
  -webkit-backdrop-filter: blur(24px) saturate(180%);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 16px;
  overflow: hidden;
  box-shadow:
    0 8px 32px rgba(0, 0, 0, 0.4),
    inset 0 1px 0 rgba(255, 255, 255, 0.1);
}

The blur(24px) on the panel and a separate blur(4px) on the backdrop gives you two distinct frosted planes. The backdrop blur softens the page so the panel reads as "on top" without going fully dark. In practice, 24px blur is the sweet spot — anything under 12px looks like a rendering artifact, anything over 40px starts tanking GPU performance on mid-range Androids.

That inset 0 1px 0 rgba(255,255,255,0.1) inner shadow is the little trick that sells the glass bevel. It's only 1px tall but it mimics how physical frosted glass catches overhead light. Try toggling it off and you'll immediately miss it. If you want to experiment more with glass CSS values, the glassmorphism generator lets you tune blur, opacity, and border color live.

Quick aside: if you're shipping a dark-mode-only product, swap rgba(255,255,255,0.08) to rgba(15,15,25,0.6) for the panel background. The blur still does the heavy lifting; the tint just anchors the panel to your color scheme.

Keyboard Navigation and Fuzzy Search

A command palette without keyboard navigation is just a fancy modal. Users who open ⌘K are keyboard users — they're not reaching for the mouse. You need ArrowUp / ArrowDown to move the active index, Enter to fire the selected command, and Escape to close.

// useCommandPalette.ts
import { useState, useCallback, useEffect } from 'react';

export interface Command {
  id: string;
  label: string;
  keywords?: string[];
  action: () => void;
}

function fuzzyMatch(query: string, target: string): boolean {
  if (!query) return true;
  const q = query.toLowerCase();
  const t = target.toLowerCase();
  let qi = 0;
  for (let i = 0; i < t.length && qi < q.length; i++) {
    if (t[i] === q[qi]) qi++;
  }
  return qi === q.length;
}

export function useCommandPalette(commands: Command[]) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [activeIdx, setActiveIdx] = useState(0);

  const filtered = commands.filter((cmd) =>
    fuzzyMatch(query, cmd.label) ||
    (cmd.keywords ?? []).some((kw) => fuzzyMatch(query, kw))
  );

  // Reset index when results change
  useEffect(() => setActiveIdx(0), [filtered.length, query]);

  const onKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setActiveIdx((i) => (i + 1) % filtered.length);
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        setActiveIdx((i) => (i - 1 + filtered.length) % filtered.length);
      } else if (e.key === 'Enter' && filtered[activeIdx]) {
        filtered[activeIdx].action();
        setOpen(false);
        setQuery('');
      }
    },
    [filtered, activeIdx]
  );

  // Global ⌘K / Ctrl+K listener
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault();
        setOpen((v) => !v);
      }
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, []);

  return { open, setOpen, query, setQuery, filtered, activeIdx, onKeyDown };
}

The fuzzy match here is deliberately simple — it's a subsequence check, not Levenshtein distance. For most command palettes with under 200 commands, this is fast enough and never blocks the main thread. If you're indexing documentation or a large dataset, look at a web worker + fuse.js, but don't over-engineer the 12-command app you're actually building.

Look, the activeIdx % filtered.length wrap is the one thing you cannot skip. If a user is at index 0 and hits ArrowUp, they should jump to the last result — that's the behavior they expect from Raycast, VS Code, everything. Get that wrong and it's immediately jarring.

Wiring It All Together

Now connect the hook to your modal shell and add the input and results list. The structure is dead simple: input at the top, divider, scrollable list below.

// App.tsx (usage example)
import { CommandPalette } from './CommandPalette';
import { useCommandPalette } from './useCommandPalette';
import { useRouter } from 'next/navigation';

const COMMANDS = [
  { id: 'home',      label: 'Go Home',         keywords: ['start', 'index'],     action: () => router.push('/') },
  { id: 'blog',      label: 'Open Blog',        keywords: ['articles', 'posts'],  action: () => router.push('/blog') },
  { id: 'pricing',   label: 'View Pricing',     keywords: ['plans', 'cost'],      action: () => router.push('/pricing') },
  { id: 'glass',     label: 'Glassmorphism Hub', keywords: ['frosted', 'blur'],   action: () => router.push('/glassmorphism') },
  { id: 'theme-dark', label: 'Toggle Dark Mode', keywords: ['theme', 'night'],   action: () => document.documentElement.classList.toggle('dark') },
];

export default function App() {
  const router = useRouter();
  const { open, setOpen, query, setQuery, filtered, activeIdx, onKeyDown } =
    useCommandPalette(COMMANDS);

  return (
    <CommandPalette open={open} onClose={() => setOpen(false)}>
      <input
        autoFocus
        placeholder="Type a command or search…"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={onKeyDown}
        className="cp-input"
      />
      <div className="cp-divider" />
      <ul className="cp-list" role="listbox">
        {filtered.map((cmd, i) => (
          <li
            key={cmd.id}
            role="option"
            aria-selected={i === activeIdx}
            className={`cp-item ${i === activeIdx ? 'cp-item--active' : ''}`}
            onClick={() => { cmd.action(); setOpen(false); setQuery(''); }}
          >
            {cmd.label}
          </li>
        ))}
        {filtered.length === 0 && (
          <li className="cp-empty">No results for "{query}"</li>
        )}
      </ul>
    </CommandPalette>
  );
}

The autoFocus on the input is non-negotiable. Users hit ⌘K and start typing immediately — they're not clicking into a text box. Because you're inside a <dialog> with showModal(), focus is trapped, so autoFocus goes straight to the input without fighting document focus.

.cp-input {
  width: 100%;
  padding: 18px 20px;
  background: transparent;
  border: none;
  outline: none;
  color: #fff;
  font-size: 16px;
  line-height: 1.5;
}

.cp-input::placeholder {
  color: rgba(255, 255, 255, 0.4);
}

.cp-divider {
  height: 1px;
  background: rgba(255, 255, 255, 0.1);
}

.cp-list {
  list-style: none;
  margin: 0;
  padding: 8px;
  max-height: 320px;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255,255,255,0.2) transparent;
}

.cp-item {
  padding: 10px 12px;
  border-radius: 8px;
  cursor: pointer;
  color: rgba(255, 255, 255, 0.85);
  font-size: 14px;
  transition: background 120ms ease;
}

.cp-item--active,
.cp-item:hover {
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
}

.cp-empty {
  padding: 20px 12px;
  color: rgba(255, 255, 255, 0.4);
  font-size: 13px;
  text-align: center;
}

The max-height: 320px with overflow-y: auto keeps the palette compact. You don't want it stretching to 600px just because there are 50 results — that's not a palette anymore, that's a modal. If you're pairing this with Empire UI's existing components, you can pull the border-radius and blur tokens from style-tokens.json so your palette matches the rest of your design system.

Open Animation Without a Motion Library

A command palette that snaps in with zero animation feels jarring. You want a 150ms scale-and-fade from 96% to 100% — enough to feel polished, not so long it slows down keyboard-heavy users. The native <dialog> element in Chrome 114+ supports @starting-style for entry animations, so you don't need Framer Motion for this.

/* Entry animation using @starting-style (Chrome 114+, Firefox 129+) */
@keyframes cp-in {
  from {
    opacity: 0;
    scale: 0.96;
  }
  to {
    opacity: 1;
    scale: 1;
  }
}

.cp-dialog[open] {
  animation: cp-in 150ms cubic-bezier(0.2, 0, 0, 1) both;
}

/* Fallback: for Safari < 17.4 just skip animation — still fine */
@supports not (selector(dialog[open])) {
  .cp-dialog[open] { opacity: 1; scale: 1; }
}

For exit animations it's trickier with native <dialog> — the element removes itself from the top layer immediately on close(), before any animation can play. The workaround is to animate to a closing state and only call el.close() in the animationend handler. Honestly, for a command palette the entry animation matters far more than exit. Users close it fast and move on.

That said, if you're on a project where exit animation is required (slide-down brand requirement, whatever), you can store a closing state, apply a cp-dialog--closing class with a reverse keyframe, listen for animationend, then fire close(). Worth noting: keep exit under 120ms or it starts feeling sluggish against rapid ⌘K toggles.

Accessibility, Performance, and the Edge Cases Nobody Warns You About

You're building a keyboard-first feature, so accessibility is table stakes, not a nice-to-have. The listbox / option roles on the list and items tell screen readers what they're navigating. aria-selected on the active item completes the contract. One thing most tutorials skip: announce the result count. Add a live region at the top of the panel:

<span
  role="status"
  aria-live="polite"
  className="sr-only"
>
  {filtered.length} result{filtered.length !== 1 ? 's' : ''}
</span>

Performance-wise, the blur filter is the expensive part. backdrop-filter: blur(24px) triggers the compositor, which is fine on desktop but can drop frames on older mobile GPUs. If your analytics show meaningful mobile usage, add a @media (prefers-reduced-motion: reduce) block that drops blur to 0 and just uses a semi-opaque background instead. Your users with vestibular disorders will thank you, and it's also the right call for low-end Android phones that shipped in 2023 with barely 2GB RAM.

The edge case nobody warns you about: if a user opens the palette while an <input> elsewhere is focused, the showModal() focus trap moves focus to your palette input — but on Safari 16, there's a race condition where the original input sometimes re-captures focus. Fix it by adding a 1-frame delay: setTimeout(() => el.showModal(), 0). Ugly, but it works. Also, test what happens when your commands list is empty on mount — some apps skip the empty state entirely and render nothing, which confuses both sighted users and screen readers.

For deeper visual reference and more glass UI patterns, browse the glassmorphism components hub. If you want to tune your backdrop and panel values visually before coding them, the glassmorphism generator is the fastest way to get real CSS you can paste directly.

FAQ

Do I need a library like cmdk or Radix to build a command palette?

No. A native <dialog> element with showModal() gives you focus trapping, Escape-to-close, and top-layer stacking for free. The hook and CSS in this article cover all the behavior you actually need for most apps — add a library only if you need accessibility audited results at scale or complex grouping.

Will backdrop-filter blur hurt performance on mobile?

On mid-range devices it can, especially at 24px. The blur composites on the GPU, which is fine for desktop. For mobile, test on a real device and fall back to a semi-opaque solid background under @media (prefers-reduced-motion: reduce) or with a JS device-tier check.

How do I add grouped sections like 'Navigation' and 'Actions'?

Filter commands into buckets by a group property, then render a heading <li> before each group in the list. Give each heading role='presentation' so screen readers skip it. The keyboard navigation index should still treat only the command items as focusable — skip group headers in your ArrowUp/ArrowDown logic.

Can I use this pattern with React Router instead of Next.js?

Absolutely. Swap useRouter from Next.js for useNavigate from React Router v6. Everything else — the dialog shell, the CSS, the hook — is framework-agnostic React. The global ⌘K listener lives on document, so routing library doesn't matter.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Glassmorphism Modal / Dialog: Frosted Overlay That Feels PremiumGlassmorphism Dashboard: Full Admin UI with Frosted-Glass CardsCommand Palette in React: ⌘K Search, Keyboard Navigation, ARIA⌘K Command Menu in React: cmdk, Keyboard Nav, Fuzzy Search