Navigation
CommandBar
A Ctrl / Cmd + K palette. Type to filter, arrow keys move a gliding highlight, Enter goes, Esc closes.
Download
- React
- Tailwind CSS v4
- motion
components/ui/command-bar.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type CommandBarItem = {
label: string;
/** Link to open when picked (if no onSelect is given). */
href?: string;
/** Group heading the item sits under. */
group?: string;
/** Small text on the right, e.g. a shortcut. */
hint?: string;
/** Icon (an svg works best). */
icon?: ReactNode;
/** Extra words that should also find this item. */
keywords?: string[];
};
type CommandBarProps = {
items: CommandBarItem[];
/** Called when an item is picked. Leave out to open item.href. */
onSelect?: (item: CommandBarItem) => void;
/** Letter that opens it with Ctrl / Cmd. false = no shortcut. */
hotkey?: string | false;
placeholder?: string;
/** Keep the dialog inside the nearest `relative` parent instead of the whole screen. */
contained?: boolean;
/** Text on the open button. */
trigger?: ReactNode;
triggerClassName?: string;
/** Colour of the highlight bar. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Dialog look. */
className?: string;
};
const noop = () => () => {};
const isMac = () => /Mac|iPhone|iPad/.test(navigator.userAgent);
/* CommandBar: a Ctrl/Cmd + K palette. Type to filter, arrows move a gliding highlight, Enter goes. */
export function CommandBar({
items,
onSelect,
hotkey = "k",
placeholder = "Search or jump to…",
contained = false,
trigger = "Search",
triggerClassName = "inline-flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 py-2 pr-2 pl-3 text-sm text-white/60 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70",
accent = "#a78bfa",
label = "Command bar",
className = "border border-white/10 bg-[#141026]/95 text-white shadow-2xl shadow-black/60 backdrop-blur-xl",
}: CommandBarProps) {
const reduce = useReducedMotion();
const button = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const mac = useSyncExternalStore(noop, isMac, () => false);
const pos = contained ? "absolute" : "fixed";
// Ctrl / Cmd + hotkey toggles the bar from anywhere on the page.
useEffect(() => {
if (!hotkey) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === hotkey.toLowerCase()) {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey]);
// Lock page scroll while open.
useEffect(() => {
if (!open || contained) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open, contained]);
const close = () => {
setOpen(false);
button.current?.focus();
};
const choose = (item: CommandBarItem) => {
close();
if (onSelect) onSelect(item);
else if (item.href) window.location.assign(item.href);
};
return (
<>
<button
ref={button}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className={triggerClassName}
>
<SearchIcon />
<span>{trigger}</span>
{hotkey && (
<kbd className="ml-4 rounded-md border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[11px] text-white/50">
{mac ? "⌘" : "Ctrl"} {hotkey.toUpperCase()}
</kbd>
)}
</button>
<AnimatePresence>
{open && (
<motion.div
key="layer"
className={`${pos} inset-0 z-50 flex items-start justify-center p-4 ${contained ? "pt-[8%]" : "pt-[12vh]"}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.18 }}
>
<div aria-hidden onClick={close} className="absolute inset-0 bg-black/55 backdrop-blur-[2px]" />
<Palette
items={items}
onChoose={choose}
onClose={close}
placeholder={placeholder}
accent={accent}
label={label}
className={className}
reduce={!!reduce}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
}
/* The dialog itself. It mounts fresh on every open, so the search box starts empty. */
function Palette({
items,
onChoose,
onClose,
placeholder,
accent,
label,
className,
reduce,
}: {
items: CommandBarItem[];
onChoose: (item: CommandBarItem) => void;
onClose: () => void;
placeholder: string;
accent: string;
label: string;
className: string;
reduce: boolean;
}) {
const uid = useId();
const input = useRef<HTMLInputElement>(null);
const list = useRef<HTMLUListElement>(null);
const [query, setQuery] = useState("");
const [index, setIndex] = useState(0);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((i) => [i.label, i.group ?? "", ...(i.keywords ?? [])].join(" ").toLowerCase().includes(q));
}, [items, query]);
const active = Math.min(index, results.length - 1);
useEffect(() => input.current?.focus(), []);
// Keep the highlighted row in view.
useEffect(() => {
list.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
}, [active]);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") return onClose();
if (e.key === "Tab") return e.preventDefault(); // focus stays in the search box
if (!results.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setIndex((i) => (Math.min(i, results.length - 1) + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setIndex((i) => (Math.min(i, results.length - 1) - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
onChoose(results[active]);
}
};
return (
<motion.div
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={onKeyDown}
initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : -8, filter: reduce ? "none" : "blur(6px)" }}
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.97, y: reduce ? 0 : -4, filter: reduce ? "none" : "blur(4px)" }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 }}
className={`relative w-full max-w-lg overflow-hidden rounded-2xl ${className}`}
>
<div className="flex items-center gap-3 border-b border-white/10 px-4">
<SearchIcon />
<input
ref={input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={`${uid}-list`}
aria-activedescendant={results.length ? `${uid}-opt-${active}` : undefined}
aria-autocomplete="list"
aria-label={label}
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIndex(0);
}}
className="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-white/35"
/>
<kbd className="rounded-md border border-white/10 px-1.5 py-0.5 font-mono text-[10px] text-white/40">Esc</kbd>
</div>
<ul ref={list} id={`${uid}-list`} role="listbox" aria-label={label} className="max-h-72 overflow-y-auto p-2">
{results.map((item, i) => {
const on = i === active;
const head = item.group && item.group !== results[i - 1]?.group;
return (
<li key={`${item.group}-${item.label}`} role="presentation">
{head && (
<p aria-hidden className="px-3 pt-2 pb-1 font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">
{item.group}
</p>
)}
<div
id={`${uid}-opt-${i}`}
data-index={i}
role="option"
aria-selected={on}
onPointerMove={() => i !== active && setIndex(i)}
onClick={() => onChoose(item)}
className={`relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors duration-150 ${on ? "text-white" : "text-white/65"}`}
>
{on && (
<motion.span
layoutId={`${uid}-hl`}
aria-hidden
className="absolute inset-0 rounded-lg bg-white/[0.07]"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 40 }}
>
<span className="absolute top-2 bottom-2 left-0 w-0.5 rounded-full" style={{ background: accent }} />
</motion.span>
)}
{item.icon && (
<span aria-hidden className="relative grid size-4 shrink-0 place-items-center [&>svg]:size-full">
{item.icon}
</span>
)}
<span className="relative min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="relative font-mono text-[11px] text-white/35">{item.hint}</span>}
</div>
</li>
);
})}
{!results.length && (
<li role="presentation" className="px-3 py-8 text-center text-sm text-white/40">
Nothing found for “{query}”.
</li>
)}
</ul>
</motion.div>
);
}
function SearchIcon() {
return (
<svg aria-hidden viewBox="0 0 16 16" className="size-4 shrink-0 opacity-60">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/command-bar.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/command-bar-demo.tsx
"use client"; import { FileText, House, LayoutGrid, Moon, Settings, User } from "lucide-react"; import { CommandBar } from "@/components/ui/command-bar"; const ITEMS = [ { label: "Home", href: "#home", group: "Pages", icon: <House /> }, { label: "Components", href: "#components", group: "Pages", icon: <LayoutGrid />, keywords: ["ui", "library"] }, { label: "Docs", href: "#docs", group: "Pages", icon: <FileText /> }, { label: "Toggle dark mode", group: "Actions", icon: <Moon />, hint: "D", keywords: ["theme"] }, { label: "Open settings", group: "Actions", icon: <Settings />, hint: "," }, { label: "Account", href: "#account", group: "Actions", icon: <User /> }, ]; export default function CommandBarExample() { // This site already uses Ctrl/Cmd + K, so the demo listens for J. // `contained` keeps the dialog inside this box. On a real page, leave both out. return ( <div className="relative grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-6"> <CommandBar items={ITEMS} hotkey="j" contained onSelect={() => {}} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href?, group?, hint?, icon?, keywords? }[] | — | Commands. group = heading; hint = text on the right; keywords = extra search words. |
| onSelect | (item) => void | — | Called when a command is picked. Leave out to open item.href. |
| hotkey | string | false | "k" | Letter that opens it with Ctrl / Cmd. false = no shortcut. |
| placeholder | string | "Search or jump to…" | Search box hint. |
| contained | boolean | false | Keep the dialog inside the nearest relative parent, not the whole screen. |
| trigger | ReactNode | "Search" | Text on the open button. |
| triggerClassName | string | glass button | Open button look. |
| accent | string | "#a78bfa" | Highlight bar colour. |
| label | string | "Command bar" | Name read out by screen readers. |
| className | string | dark glass panel | Dialog look. |