Command Palette

A search palette pops in with a quick scale, filters as you type, and a highlight glides between results under the arrow keys.

  • JS
  • Layout
  • Toggle
  • Added

AI prompt

Create a command palette in React and CSS. A trigger opens a panel that animates from opacity 0, scale(0.96), and translateY(-6px) to rest over 180ms with cubic-bezier(0.16, 1, 0.3, 1). The panel has a search input (focused on open) and a listbox of commands filtered by a case-insensitive includes match. Keep an active index: ArrowDown and ArrowUp move it with wrap-around, Enter runs the active command and closes, and Escape closes. Instead of recolouring rows, render one absolutely positioned highlight behind the list and move it with transform: translateY(index × row height) and a 160ms transition, so it glides between rows. Mark the input role="combobox" with aria-expanded, aria-controls, and aria-activedescendant pointing at the active option. Show "No matches" when the filter is empty. Under prefers-reduced-motion, skip the scale and the glide. The commands are New file, Search motions, Toggle theme, Copy link, and Open settings.

Source

Demo.tsx
'use client'

import { useId, useState } from 'react'
import s from './demo.module.css'

const ROW = 34

export default function CommandPalette({ labels }: { labels: Record<string, string> }) {
  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState('')
  const [active, setActive] = useState(0)
  const id = useId()
  const items = labels.items.split('|').filter((t) => t.toLowerCase().includes(query.toLowerCase()))

  const close = () => {
    setOpen(false)
    setQuery('')
    setActive(0)
  }

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') close()
    if (!items.length) return
    if (e.key === 'ArrowDown') setActive((a) => (a + 1) % items.length)
    if (e.key === 'ArrowUp') setActive((a) => (a - 1 + items.length) % items.length)
    if (e.key === 'Enter') close()
    if (e.key.startsWith('Arrow')) e.preventDefault()
  }

  if (!open)
    return (
      <button type="button" className={s.trigger} onClick={() => setOpen(true)}>
        {labels.open} <kbd className={s.kbd}>⌘K</kbd>
      </button>
    )

  return (
    <div className={s.panel} onKeyDown={onKey}>
      <input
        className={s.input}
        role="combobox"
        aria-expanded="true"
        aria-controls={id}
        aria-activedescendant={items.length ? `${id}-${active}` : undefined}
        placeholder={labels.placeholder}
        value={query}
        autoFocus
        onBlur={(e) => !e.currentTarget.parentElement?.contains(e.relatedTarget) && close()}
        onChange={(e) => {
          setQuery(e.target.value)
          setActive(0)
        }}
      />
      <ul id={id} role="listbox" className={s.list}>
        {items.length > 0 && <li className={s.highlight} style={{ transform: `translateY(${active * ROW}px)` }} aria-hidden="true" />}
        {items.map((t, i) => (
          <li
            key={t}
            id={`${id}-${i}`}
            role="option"
            aria-selected={i === active}
            className={s.option}
            onPointerEnter={() => setActive(i)}
            onPointerDown={(e) => e.preventDefault()}
            onClick={close}
          >
            {t}
          </li>
        ))}
        {!items.length && <li className={s.empty}>{labels.empty}</li>}
      </ul>
    </div>
  )
}
demo.module.css
.trigger {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  height: 40px;
  padding: 0 10px 0 16px;
  border: 1px solid var(--stage-rule);
  border-radius: var(--radius-sm);
  background: var(--stage-surface);
  color: var(--stage-ink);
  font: 500 var(--text-sm) / 1 var(--font-body);
  cursor: pointer;
}

.trigger:hover { border-color: var(--stage-accent); }
.trigger:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

.kbd {
  padding: 3px 6px;
  border-radius: 5px;
  background: var(--stage-bg);
  color: var(--stage-muted);
  font: 500 var(--text-xs) / 1 var(--font-mono);
}

.panel {
  width: min(280px, 100%);
  border: 1px solid var(--stage-rule);
  border-radius: 14px;
  background: var(--stage-surface);
  box-shadow: 0 16px 40px oklch(0% 0 0 / 0.45);
  overflow: hidden;
  transform-origin: 50% 0;
  animation: pop 180ms var(--ease-out);
}

@keyframes pop {
  from { opacity: 0; transform: translateY(-6px) scale(0.96); }
}

.input {
  width: 100%;
  height: 42px;
  padding: 0 14px;
  border: 0;
  border-bottom: 1px solid var(--stage-rule);
  outline: none;
  background: transparent;
  color: var(--stage-ink);
  font: 400 var(--text-sm) / 1 var(--font-body);
}

.input::placeholder { color: var(--stage-muted); }

.list {
  position: relative;
  margin: 0;
  padding: 6px;
  list-style: none;
}

.highlight {
  position: absolute;
  left: 6px;
  right: 6px;
  top: 6px;
  height: 34px;
  border-radius: 8px;
  background: var(--stage-bg);
  box-shadow: inset 2px 0 0 var(--stage-accent);
  transition: transform 160ms var(--ease-out);
}

.option, .empty {
  position: relative;
  display: flex;
  align-items: center;
  height: 34px;
  padding: 0 10px;
  font: 400 var(--text-sm) / 1 var(--font-body);
  color: var(--stage-muted);
  cursor: pointer;
}

.option[aria-selected='true'] { color: var(--stage-ink); }
.empty { cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .panel { animation: none; }
  .highlight { transition: none; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.