命令面板
搜索面板以快速缩放弹出,输入即筛选,用方向键切换时高亮块在结果之间滑动。
用 React 和 CSS 制作命令面板。触发按钮打开一个面板,在 180ms 内从 opacity 0、scale(0.96)、translateY(-6px) 过渡到静止状态,缓动 cubic-bezier(0.16, 1, 0.3, 1)。面板包含一个搜索框(打开时自动聚焦)和一个命令列表,按不区分大小写的包含匹配进行筛选。维护一个当前项索引:ArrowDown 和 ArrowUp 循环移动,Enter 执行当前命令并关闭,Escape 关闭。不要给每一行改颜色,而是在列表后面放一个绝对定位的高亮块,用 transform: translateY(索引 × 行高) 和 160ms 过渡移动它,让它在行之间滑动。输入框设置 role="combobox",并带上 aria-expanded、aria-controls 和指向当前选项的 aria-activedescendant。筛选结果为空时显示“没有匹配项”。在 prefers-reduced-motion 下去掉缩放和滑动。命令为新建文件、搜索动效、切换主题、复制链接、打开设置。
源代码
'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; }
}