Documentation menu

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

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco palette component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/palette.json

Manual

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.css

Needs moco items: tokens, hooks

Usage

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;
}