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.
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
'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>
)
}
.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; }
}