palette
Cmd-K fuzzy command palette with subsequence matching, character highlighting, focus trap, and on-page section jumps.
command-palettesearchkeyboarddialog
Preview
Press ⌘K (or Ctrl-K) and type a few letters — matching is by subsequence, so “pcn” finds “Prompt caching by the numbers”.
A section on this page
Sections with ids are scraped into the index each time the palette opens.
Another section
Selecting a section scrolls to it; selecting a post or page navigates.
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/palette.jsonManual
Copy each file from the Source section into its target path:
registry/palette/palette.tsx → components/moco/palette.tsx
registry/palette/palette.css → components/moco/palette.cssUsage
usage.tsx
"use client";
import { Palette, type IndexEntry } from "./palette";
const INDEX: IndexEntry[] = [
{ kind: "post", label: "Prompt caching by the numbers", href: "#" },
{ kind: "post", label: "The island pattern", href: "#" },
{ kind: "page", label: "About", href: "#" },
];
export default function PaletteDemo() {
return (
<div className="prose" style={{ maxWidth: "42rem", margin: "0 auto", padding: "2rem 1rem" }}>
<p>
Press <kbd>⌘K</kbd> (or <kbd>Ctrl-K</kbd>) and type a few letters — matching is by
subsequence, so “pcn” finds “Prompt caching by the numbers”.
</p>
<h2 id="demo-section-one">A section on this page</h2>
<p>Sections with ids are scraped into the index each time the palette opens.</p>
<h2 id="demo-section-two">Another section</h2>
<p>Selecting a section scrolls to it; selecting a post or page navigates.</p>
<Palette index={INDEX} onNavigate={(href) => console.log("navigate:", href)} />
</div>
);
}
Source
components/moco/palette.tsx
"use client";
import * as React from "react";
import "./palette.css";
import { smoothScrollTo } from "@/components/moco/hooks";
export interface IndexEntry {
kind: "post" | "page" | "section";
label: string;
href?: string;
id?: string;
}
export interface PaletteProps {
index: IndexEntry[];
/** Headings scraped into the index each time the palette opens. */
sectionSelector?: string;
/** Called for entries with an href. Defaults to a full navigation. */
onNavigate?: (href: string) => void;
}
/** Subsequence match. Returns matched character indices, or null. */
function fuzzy(needle: string, hay: string): number[] | null {
if (!needle) return [];
const h = hay.toLowerCase();
const n = needle.toLowerCase();
const hits: number[] = [];
let i = 0;
for (const ch of n) {
const at = h.indexOf(ch, i);
if (at === -1) return null;
hits.push(at);
i = at + 1;
}
return hits;
}
function Highlighted({ text, hits }: { text: string; hits: number[] }) {
const set = new Set(hits);
return (
<span>
{text.split("").map((c, i) => (set.has(i) ? <mark key={i}>{c}</mark> : <span key={i}>{c}</span>))}
</span>
);
}
/** ⌘K / Ctrl-K. Traps focus while open, restores it on close. */
export function Palette({
index,
sectionSelector = ".prose h2[id]",
onNavigate,
}: PaletteProps) {
const [open, setOpen] = React.useState(false);
const [q, setQ] = React.useState("");
const [sel, setSel] = React.useState(0);
const [sections, setSections] = React.useState<IndexEntry[]>([]);
const restore = React.useRef<HTMLElement | null>(null);
const input = React.useRef<HTMLInputElement>(null);
const box = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
React.useEffect(() => {
if (!open) {
restore.current?.focus();
return;
}
restore.current = document.activeElement as HTMLElement;
setQ("");
setSel(0);
setSections(
Array.from(document.querySelectorAll<HTMLElement>(sectionSelector)).map((h) => ({
kind: "section" as const,
label: h.textContent ?? "",
id: h.id,
})),
);
const t = setTimeout(() => input.current?.focus(), 0);
return () => clearTimeout(t);
}, [open, sectionSelector]);
const all = React.useMemo(() => [...sections, ...index], [sections, index]);
const results = React.useMemo(() => {
return all
.map((e) => ({ e, hits: fuzzy(q, e.label) }))
.filter((r): r is { e: IndexEntry; hits: number[] } => r.hits !== null)
.slice(0, 40);
}, [all, q]);
const go = (e: IndexEntry) => {
setOpen(false);
if (e.kind === "section" && e.id) smoothScrollTo(e.id);
else if (e.href) (onNavigate ?? ((href: string) => location.assign(href)))(e.href);
};
if (!open) return null;
return (
<div
className="moco-cmdk-scrim"
onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)}
>
<div
ref={box}
className="moco-cmdk"
role="dialog"
aria-modal="true"
aria-label="Search"
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((v) => Math.min(results.length - 1, v + 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSel((v) => Math.max(0, v - 1));
}
if (e.key === "Enter" && results[sel]) {
e.preventDefault();
go(results[sel].e);
}
if (e.key === "Tab") {
// Focus stays in the input; there is nowhere else to go.
e.preventDefault();
}
}}
>
<input
ref={input}
value={q}
placeholder="Jump to a post, a section, a page…"
aria-label="Search posts and sections"
onChange={(e) => {
setQ(e.target.value);
setSel(0);
}}
/>
{results.length ? (
<ul role="listbox" aria-label="Results">
{results.map((r, i) => (
<li key={`${r.e.kind}-${r.e.label}-${i}`} role="option" aria-selected={i === sel}>
<button type="button" onMouseEnter={() => setSel(i)} onClick={() => go(r.e)}>
<Highlighted text={r.e.label} hits={r.hits} />
<span className="moco-kind">{r.e.kind}</span>
</button>
</li>
))}
</ul>
) : (
<div className="moco-empty">Nothing matches “{q}”.</div>
)}
</div>
</div>
);
}
components/moco/palette.css
.moco-cmdk-scrim {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(3px);
display: flex;
justify-content: center;
padding: 12vh 1rem 2rem;
}
.moco-cmdk {
width: min(560px, 100%);
height: max-content;
max-height: 70vh;
background: var(--moco-bg);
border: 1px solid var(--moco-line);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.moco-cmdk input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--moco-line);
padding: 0.9rem 1rem;
font: inherit;
font-size: 14px;
color: var(--moco-ink);
outline: none;
}
.moco-cmdk input::placeholder {
color: var(--moco-faint);
}
.moco-cmdk ul {
list-style: none;
margin: 0;
padding: 0.35rem;
overflow-y: auto;
}
.moco-cmdk li button {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
width: 100%;
text-align: left;
padding: 0.45rem 0.65rem;
font-size: 13px;
color: var(--moco-muted);
border-radius: 2px;
/* Button reset the host app may not provide. */
font-family: inherit;
background: none;
border: 0;
cursor: pointer;
}
.moco-cmdk li[aria-selected="true"] button {
background: var(--moco-hover-wash);
color: var(--moco-ink);
}
.moco-cmdk .moco-kind {
color: var(--moco-faint);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
flex: none;
}
.moco-cmdk mark {
background: none;
color: var(--moco-accent-dk);
font-weight: 700;
}
.moco-cmdk .moco-empty {
padding: 1rem;
color: var(--moco-faint);
font-size: 13px;
}