Skip to content
Motif 母题

Command Palette

Fuzzy filtering as you type with matched letters highlighted, a selection bar that glides between rows on arrow keys, and Enter to run. Groups, shortcut hints and an empty state included. Made for a global ⌘K search and quick jump.

Loading preview

Tune

Presets

Look
16 px
Layout
10
Motion
0.26 s
0.10
Content
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 Paco Coursey
// Source: https://github.com/pacocoursey/cmdk/blob/dd2250e/cmdk/src/command-score.ts
// Modified by Motif; see the item's provenance.
import { cn } from '@motif/runtime'
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
/* @motif:defaults */
export const defaults = {
color: '#8b5cf6',
radius: 16,
size: 'md',
spring: {
visualDuration: 0.26,
bounce: 0.1,
},
maxRows: 10,
placeholder: 'Search or jump to…',
showFooter: true,
}
/* @motif:end */
export type CommandItem = {
id: string
label: string
icon?: ReactNode
/** 右侧的快捷键,例如 ['⌘', 'N']。 */
shortcut?: string[]
/** 不显示但参与匹配的关键词。 */
keywords?: string[]
hint?: string
}
export type CommandGroup = { heading: string; items: CommandItem[] }
export type InputSearchCommandProps = Partial<typeof defaults> & {
groups?: CommandGroup[]
/** 搜索词(受控)。 */
query?: string
onQueryChange?: (query: string) => void
/** 强制高亮某一项(演示自动播放用)。 */
highlight?: string
/** 强制显示某一项被选中的闪烁反馈。 */
flash?: string
onSelect?: (item: CommandItem) => void
/** 强制显示聚焦外观。 */
focused?: boolean
emptyText?: string
className?: string
style?: CSSProperties
}
const icon = (path: ReactNode) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{path}
</svg>
)
const SAMPLE: CommandGroup[] = [
{
heading: 'Jump to',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: icon(<><rect x="4" y="4" width="7" height="8" rx="1.5" /><rect x="13" y="4" width="7" height="5" rx="1.5" /><rect x="13" y="11" width="7" height="9" rx="1.5" /><rect x="4" y="14" width="7" height="6" rx="1.5" /></>), shortcut: ['G', 'D'] },
{ id: 'projects', label: 'Projects', icon: icon(<path d="M4 7.5A1.5 1.5 0 0 1 5.5 6H10l2 2.5h6.5A1.5 1.5 0 0 1 20 10v7.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z" />), shortcut: ['G', 'P'] },
{ id: 'inbox', label: 'Inbox', icon: icon(<><path d="M4 13.5 6.5 6h11l2.5 7.5" /><path d="M4 13.5V18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4.5h-4.5a2.5 2.5 0 0 1-5 0z" /></>), shortcut: ['G', 'I'], keywords: ['messages', 'mail'] },
],
},
{
heading: 'Actions',
items: [
{ id: 'new-project', label: 'Create new project', icon: icon(<path d="M12 5v14M5 12h14" />), shortcut: ['⌘', 'N'], keywords: ['add', 'start'] },
{ id: 'invite', label: 'Invite a teammate', icon: icon(<><circle cx="10" cy="8.5" r="3.2" /><path d="M4 19c.7-3.2 3-4.8 6-4.8s5.3 1.6 6 4.8M18.5 8v5M16 10.5h5" /></>), keywords: ['share', 'people'] },
{ id: 'theme', label: 'Toggle dark mode', icon: icon(<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />), shortcut: ['⌘', '⇧', 'L'], keywords: ['theme', 'appearance', 'light'] },
],
},
{
heading: 'Settings',
items: [
{ id: 'profile', label: 'Edit profile', icon: icon(<><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20c.8-3.6 3.6-5.5 7-5.5s6.2 1.9 7 5.5" /></>), keywords: ['account'] },
{ id: 'billing', label: 'Billing and plans', icon: icon(<><rect x="3.5" y="6" width="17" height="12" rx="2.5" /><path d="M3.5 10.5h17" /></>), keywords: ['invoice', 'payment'] },
{ id: 'shortcuts', label: 'Keyboard shortcuts', icon: icon(<><rect x="3" y="7" width="18" height="10" rx="2" /><path d="M7 11h.01M11 11h.01M15 11h.01M8 14h8" /></>), shortcut: ['⌘', '/'] },
],
},
]
/** 模糊匹配:先看子串(词首更高分),再看子序列(连续、词首加分);返回分数和命中的字符下标。 */
export function match(query: string, text: string): { score: number; indices: number[] } {
if (!query) return { score: 1, indices: [] }
const q = query.toLowerCase()
const t = text.toLowerCase()
const at = t.indexOf(q)
if (at >= 0) {
const atWord = at === 0 || /[\s\-_/]/.test(t[at - 1]!)
return { score: (atWord ? 0.95 : 0.7) - at * 0.004, indices: Array.from({ length: q.length }, (_, i) => at + i) }
}
const indices: number[] = []
let score = 0
let from = 0
let previous = -2
for (const ch of q) {
const found = t.indexOf(ch, from)
if (found < 0) return { score: 0, indices: [] }
const atWord = found === 0 || /[\s\-_/]/.test(t[found - 1]!)
score += (found === previous + 1 ? 0.08 : 0.03) + (atWord ? 0.05 : 0)
indices.push(found)
previous = found
from = found + 1
}
return { score: Math.min(0.6, score / q.length + 0.05), indices }
}
function Highlighted({ text, indices }: { text: string; indices: number[] }) {
if (indices.length === 0) return <>{text}</>
const hit = new Set(indices)
return (
<>
{Array.from(text).map((ch, i) => (hit.has(i) ? <mark key={i} className="bg-transparent font-semibold text-(--c)">{ch}</mark> : <span key={i}>{ch}</span>))}
</>
)
}
/** 命令面板:模糊搜索、分组、方向键 / Home / End / Enter 导航,高亮条在选项之间滑动(combobox + listbox 模式)。 */
export function InputSearchCommand({ groups = SAMPLE, query, onQueryChange, highlight, flash, onSelect, focused, emptyText = 'No results found.', className, style, ...props }: InputSearchCommandProps) {
const { color, radius, size, spring, maxRows, placeholder, showFooter } = { ...defaults, ...props }
const reduced = useReducedMotion()
const uid = useId()
const [innerQuery, setInnerQuery] = useState('')
const [activeId, setActiveId] = useState<string | null>(null)
const [flashed, setFlashed] = useState<string | null>(null)
const listRef = useRef<HTMLDivElement>(null)
const text = query ?? innerQuery
const rowHeight = size === 'sm' ? 36 : size === 'lg' ? 48 : 42
const visible = useMemo(() => {
const scored = groups
.map((group) => {
const items = group.items
.map((item) => {
const label = match(text, item.label)
const keyword = Math.max(0, ...(item.keywords ?? []).map((k) => match(text, k).score * 0.8))
return { item, score: Math.max(label.score, keyword), indices: label.score >= keyword ? label.indices : [] }
})
.filter((entry) => entry.score > 0)
if (text) items.sort((a, b) => b.score - a.score)
return { heading: group.heading, items, best: Math.max(0, ...items.map((entry) => entry.score)) }
})
.filter((group) => group.items.length > 0)
if (text) scored.sort((a, b) => b.best - a.best)
return scored
}, [groups, text])
const flat = visible.flatMap((group) => group.items.map((entry) => entry.item.id))
const active = highlight ?? (activeId && flat.includes(activeId) ? activeId : flat[0])
useEffect(() => {
const list = listRef.current
const el = active ? list?.querySelector<HTMLElement>(`[data-id="${CSS.escape(active)}"]`) : null
if (!list || !el) return
const top = el.offsetTop
const bottom = top + el.offsetHeight
if (top - 30 < list.scrollTop) list.scrollTop = Math.max(0, top - 30)
else if (bottom + 8 > list.scrollTop + list.clientHeight) list.scrollTop = bottom + 8 - list.clientHeight
}, [active])
const setQuery = (next: string) => {
if (query === undefined) setInnerQuery(next)
onQueryChange?.(next)
setActiveId(null)
}
const choose = (id: string) => {
const item = groups.flatMap((group) => group.items).find((candidate) => candidate.id === id)
if (!item) return
setFlashed(id)
window.setTimeout(() => setFlashed(null), 320)
onSelect?.(item)
}
const onKeyDown = (event: KeyboardEvent) => {
if (flat.length === 0) return
const at = Math.max(0, flat.indexOf(active ?? ''))
let next: number | null = null
if (event.key === 'ArrowDown') next = (at + 1) % flat.length
else if (event.key === 'ArrowUp') next = (at - 1 + flat.length) % flat.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = flat.length - 1
else if (event.key === 'Enter' && active) {
event.preventDefault()
choose(active)
return
}
if (next === null) return
event.preventDefault()
setActiveId(flat[next]!)
}
const transition = reduced ? { duration: 0 } : { type: 'spring' as const, visualDuration: spring.visualDuration, bounce: spring.bounce }
const flashing = flash ?? flashed
const [inputFocus, setInputFocus] = useState(false)
const isFocused = focused ?? inputFocus
return (
<div
className={cn('w-full overflow-hidden border bg-popover text-popover-foreground shadow-2xl shadow-black/40', className)}
style={{ '--c': color, borderRadius: radius, ...style } as CSSProperties}
>
<div className="flex h-14 items-center gap-3 border-b px-4">
<svg viewBox="0 0 24 24" className={cn('size-[18px] shrink-0 transition-colors duration-150', isFocused ? 'text-(--c)' : 'text-muted-foreground')} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<circle cx="11" cy="11" r="6.5" />
<path d="m20 20-4.2-4.2" />
</svg>
<input
role="combobox"
aria-expanded="true"
aria-controls={`${uid}-list`}
aria-autocomplete="list"
aria-activedescendant={active ? `${uid}-${active}` : undefined}
aria-label="Search commands"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={text}
placeholder={placeholder}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && text) {
event.preventDefault()
setQuery('')
} else onKeyDown(event)
}}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
className="h-full min-w-0 flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground/60"
style={{ caretColor: color }}
/>
{text ? (
<button type="button" aria-label="Clear search" onClick={() => setQuery('')} className="grid size-6 place-items-center rounded-md text-muted-foreground outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-(--c)">
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><path d="m4 4 8 8M12 4l-8 8" /></svg>
</button>
) : (
<kbd className="rounded-md border bg-muted px-1.5 py-0.5 font-sans text-[10px] font-medium text-muted-foreground">Esc</kbd>
)}
</div>
<div ref={listRef} id={`${uid}-list`} role="listbox" aria-label="Results" className="relative isolate overflow-y-auto overscroll-contain p-2" style={{ maxHeight: rowHeight * maxRows + 56 }}>
{visible.length === 0 && (
<div className="grid place-items-center gap-1 py-10 text-center">
<div className="text-sm font-medium">{emptyText}</div>
<div className="max-w-56 truncate text-xs text-muted-foreground">Nothing matches “{text}”.</div>
</div>
)}
{visible.map((group) => (
<div key={group.heading} role="group" aria-label={group.heading} className="mb-1 last:mb-0">
<div aria-hidden className="px-3 pt-2 pb-1 text-[11px] font-medium tracking-wide text-muted-foreground">{group.heading}</div>
{group.items.map(({ item, indices }) => {
const isActive = item.id === active
return (
<div
key={item.id}
id={`${uid}-${item.id}`}
data-id={item.id}
role="option"
aria-selected={isActive}
onPointerMove={() => item.id !== activeId && setActiveId(item.id)}
onClick={() => choose(item.id)}
className={cn('relative flex cursor-default items-center gap-3 px-3 select-none', isActive ? 'text-foreground' : 'text-foreground/75')}
style={{ height: rowHeight, borderRadius: Math.max(radius - 6, 6) }}
>
{isActive && (
<motion.div
layoutId={`${uid}-active`}
aria-hidden
transition={transition}
className="absolute inset-0 -z-10 bg-accent"
style={{ borderRadius: Math.max(radius - 6, 6), backgroundColor: flashing === item.id ? 'color-mix(in oklab, var(--c) 32%, transparent)' : undefined }}
/>
)}
{isActive && <motion.span layoutId={`${uid}-bar`} aria-hidden transition={transition} className="absolute top-2.5 bottom-2.5 left-0 -z-10 w-0.5 rounded-full bg-(--c)" />}
<span className={cn('relative grid size-[18px] shrink-0 place-items-center transition-colors duration-150 [&>svg]:size-full', isActive ? 'text-(--c)' : 'text-muted-foreground')}>{item.icon}</span>
<span className="relative min-w-0 flex-1 truncate text-sm">
<Highlighted text={item.label} indices={indices} />
</span>
{item.shortcut && (
<span className="relative flex gap-1">
{item.shortcut.map((key, i) => (
<kbd key={i} className="grid h-5 min-w-5 place-items-center rounded border bg-background/60 px-1 font-sans text-[10px] text-muted-foreground">{key}</kbd>
))}
</span>
)}
</div>
)
})}
</div>
))}
</div>
{showFooter && (
<div className="flex items-center gap-4 border-t bg-muted/30 px-4 py-2.5 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5"><kbd className="rounded border bg-background/60 px-1 font-sans">↑</kbd><kbd className="rounded border bg-background/60 px-1 font-sans">↓</kbd>navigate</span>
<span className="flex items-center gap-1.5"><kbd className="rounded border bg-background/60 px-1 font-sans">↵</kbd>select</span>
<span className="ml-auto tabular-nums" aria-live="polite">{flat.length} {flat.length === 1 ? 'result' : 'results'}</span>
</div>
)}
</div>
)
}

More like this

More Inputs →