Forms
SearchCommand
An inline search box with grouped results under it. The match lights up in each result, a highlight glides with the arrow keys, and one key jumps to the box from anywhere.
Download
- React
- Tailwind CSS v4
- motion
components/ui/search-command.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type SearchCommandItem = {
id: string;
label: string;
/** Heading the result sits under. */
group?: string;
/** Small text on the right of the result. */
hint?: string;
/** Extra words that also find this result. */
keywords?: string;
};
type SearchCommandProps = {
items: SearchCommandItem[];
/** Called when a result is picked. */
onSelect?: (item: SearchCommandItem) => void;
/** Name for screen readers. */
label?: string;
placeholder?: string;
/** Key that jumps to the box from anywhere on the page. Empty string turns it off. */
hotkey?: string;
/** Highlight and match colour. */
accent?: string;
/** Wrapper look (width etc.). */
className?: string;
};
function Match({ text, query }: { text: string; query: string }) {
const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
if (at < 0) return text;
return (
<>
{text.slice(0, at)}
<mark className="bg-transparent font-semibold text-[var(--sc)]">{text.slice(at, at + query.length)}</mark>
{text.slice(at + query.length)}
</>
);
}
/* SearchCommand: an inline search box with grouped results under it. The match lights up in each result, a highlight glides with the arrow keys, and one key jumps to the box from anywhere. */
export function SearchCommand({
items,
onSelect,
label = "Search",
placeholder = "Search…",
hotkey = "/",
accent = "#a78bfa",
className = "w-full max-w-md",
}: SearchCommandProps) {
const reduce = useReducedMotion();
const uid = useId();
const listId = `${uid}-list`;
const optId = (i: number) => `${uid}-opt-${i}`;
const input = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState(false);
const [active, setActive] = useState(0);
const query = text.trim();
const groups = useMemo(() => {
const q = query.toLowerCase();
const found = new Map<string, SearchCommandItem[]>();
for (const item of items) {
if (!`${item.label} ${item.keywords ?? ""}`.toLowerCase().includes(q)) continue;
const key = item.group ?? "";
found.set(key, [...(found.get(key) ?? []), item]);
}
return [...found];
}, [items, query]);
const results = groups.flatMap(([, list]) => list);
const current = Math.min(active, results.length - 1);
useEffect(() => {
if (open) document.getElementById(`${uid}-opt-${current}`)?.scrollIntoView({ block: "nearest" });
}, [open, current, uid]);
useEffect(() => {
if (!hotkey) return;
const onKey = (e: globalThis.KeyboardEvent) => {
const el = e.target as HTMLElement;
if (e.key !== hotkey || e.ctrlKey || e.metaKey || e.altKey) return;
if (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return;
e.preventDefault();
input.current?.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey]);
const pick = (item?: SearchCommandItem) => {
if (!item) return;
onSelect?.(item);
setText("");
setActive(0);
setOpen(false);
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!open) return setOpen(true);
const dir = e.key === "ArrowDown" ? 1 : -1;
const size = results.length || 1;
setActive((n) => (Math.min(n, size - 1) + dir + size) % size);
} else if (e.key === "Enter" && open) {
e.preventDefault();
pick(results[current]);
} else if (e.key === "Escape") {
e.preventDefault();
if (text) setText("");
else if (open) setOpen(false);
else input.current?.blur();
setActive(0);
}
};
const optionIndex = (item: SearchCommandItem) => results.indexOf(item);
return (
<div role="search" className={className} style={{ "--sc": accent } as CSSProperties}>
<div className="relative">
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="pointer-events-none absolute top-1/2 left-4 size-[18px] -translate-y-1/2 text-white/45">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.8-3.8" />
</svg>
<input
ref={input}
type="text"
role="combobox"
aria-label={label}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={open && current >= 0 ? optId(current) : undefined}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
value={text}
onChange={(e) => {
setText(e.target.value);
setActive(0);
setOpen(true);
}}
onFocus={() => {
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
setOpen(false);
}}
onClick={() => setOpen(true)}
onKeyDown={onKey}
className="h-12 w-full rounded-xl border border-white/12 bg-[#0f0d1c] pr-12 pl-11 text-base text-white outline-none transition-colors duration-200 placeholder:text-white/40 hover:border-white/25 focus:border-[var(--sc)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--sc)_30%,transparent)] motion-reduce:transition-none"
/>
{hotkey && !focused && (
<kbd className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded-md border border-white/15 px-1.5 py-0.5 font-mono text-xs text-white/50">{hotkey}</kbd>
)}
<AnimatePresence>
{open && (
<motion.div
onMouseDown={(e) => e.preventDefault()}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 34 }}
className="absolute inset-x-0 top-full z-20 mt-2 max-h-64 origin-top overflow-auto rounded-[14px] border border-white/10 bg-[#141026] p-1.5 shadow-2xl shadow-black/50"
>
<div id={listId} role="listbox" aria-label={label}>
{groups.map(([group, list]) => (
<div key={group} role="group" aria-label={group || undefined}>
{group && (
<div aria-hidden className="px-3 pt-2 pb-1 text-xs font-medium text-white/40">
{group}
</div>
)}
{list.map((item) => {
const i = optionIndex(item);
const isActive = i === current;
return (
<div
key={item.id}
id={optId(i)}
role="option"
aria-selected={isActive}
onMouseMove={() => !isActive && setActive(i)}
onClick={() => pick(item)}
className={`relative flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-[15px] ${isActive ? "text-white" : "text-white/70"}`}
>
{isActive && (
<motion.span
layoutId={`${uid}-hl`}
aria-hidden
className="absolute inset-0 rounded-lg"
style={{ background: "color-mix(in srgb, var(--sc) 16%, transparent)" }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
/>
)}
<span className="relative min-w-0 flex-1 truncate">
<Match text={item.label} query={query} />
</span>
{item.hint && <span className="relative shrink-0 text-xs text-white/40">{item.hint}</span>}
</div>
);
})}
</div>
))}
</div>
{results.length === 0 && <p className="px-3 py-5 text-center text-sm text-white/50">Nothing found for “{query}”</p>}
</motion.div>
)}
</AnimatePresence>
<p aria-live="polite" className="sr-only">
{open && query ? `${results.length} results` : ""}
</p>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/search-command.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/search-command-demo.tsx
"use client"; import { useState } from "react"; import { SearchCommand } from "@/components/ui/search-command"; const ITEMS = [ { id: "home", label: "Home", group: "Pages" }, { id: "pricing", label: "Pricing", group: "Pages", keywords: "plans cost" }, { id: "docs", label: "Documentation", group: "Pages", keywords: "guide help" }, { id: "invite", label: "Invite a teammate", group: "Actions", hint: "Team" }, { id: "export", label: "Export as CSV", group: "Actions", keywords: "download" }, { id: "theme", label: "Switch theme", group: "Actions", keywords: "dark light" }, ]; export default function SearchCommandExample() { const [picked, setPicked] = useState(""); return ( <div className="flex h-full flex-col items-center bg-[#0b0a14] p-5 pt-12"> <SearchCommand items={ITEMS} onSelect={(item) => setPicked(item.label)} placeholder="Search pages and actions…" /> <p aria-live="polite" className="mt-3 h-5 text-sm text-white/55"> {picked && `Picked: ${picked}`} </p> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { id, label, group?, hint?, keywords? }[] | — | Everything that can be found. keywords = extra words that also find it. |
| onSelect | (item) => void | — | Called when a result is picked. |
| label | string | "Search" | Name for screen readers. |
| placeholder | string | "Search…" | Text in the empty box. |
| hotkey | string | "/" | Key that jumps to the box. Empty string turns it off. |
| accent | string | "#a78bfa" | Highlight and match colour. |
| className | string | "w-full max-w-md" | Wrapper look. |