The one-second demo
You hit Cmd+K anywhere on the site. A modal fades in, a text input takes focus, and the body scroll locks. You type "cip". Two results appear: the CipherStack blog post, and the CipherStack external link. You hit Enter. The router pushes to the post. Elapsed time from keystroke to navigation: under a second.
That is the whole feature. The rest of this post is what actually runs behind those three keystrokes.
What lives in the index
Every item in the palette is a PaletteItem with a kind discriminator. There are six kinds. Here is the exact shape from src/components/command-palette.tsx:
export type PaletteItem = {
id: string;
title: string;
subtitle?: string;
href: string;
external?: boolean;
kind: "post" | "project" | "page" | "section" | "external" | "action";
keywords?: string;
};The index is built once at build time in src/lib/palette-index.ts and pushed into React via app/layout.tsx. There are 4 top-level pages, 9 in-page anchor sections on the home page, one post entry per MDX file under content/ (currently 58), one project entry per visible resume project (43 after the two hidden ones drop out), 5 external surfaces (Dyx voicemail, CipherStack, GitHub, LinkedIn, X), and 3 actions (email, resume request, RSS). That works out to 122 items today. The dynamic-import loading comment in palette-mount.tsx still says "79-item palette body", a stale number from an earlier snapshot that I should refresh.
Three separate data sources feed the builder: getBlogPosts() reads MDX from disk, VISIBLE_PROJECTS and DATA come from the resume module, and the pages/sections/externals/actions are hand-listed inline. The builder is deliberately dumb. No caching, no memoization. It runs once per build, and the array it returns is serialized into the RSC payload.
The keywords field is where the fuzzy match wins
Fuse.js is happy to match "cmu" against the string "CMU MS-AIE". It is not happy to match "carnegie mellon" against that string, because there is no shared substring. So the education section entry carries an explicit keywords: "cmu carnegie mellon msaie". Same trick for the About section ("about intro"), for the voicemail external ("voicemail dyx ai assistant call phone"), for the RSS action ("rss subscribe feed").
For posts, the keywords field is the space-joined tag list from front-matter. That means typing "go" surfaces every Go post via the tags, even if the word never appears in the title. For projects, it is the joined technologies array from the resume.
I did not build a stemmer or a synonym map. keywords is the escape hatch when the title alone would not match the way I search for it.
The Fuse config, in one paragraph
new Fuse(items, {
keys: [
{ name: "title", weight: 0.5 },
{ name: "subtitle", weight: 0.25 },
{ name: "keywords", weight: 0.25 },
],
threshold: 0.4,
ignoreLocation: true,
});Title is worth twice as much as subtitle or keywords. threshold: 0.4 is the loosest match I tolerate before results become noise (Fuse ranges from 0.0 exact to 1.0 anything). ignoreLocation: true means a match at character 40 of the title counts as much as a match at character 0. Without that flag, "sentinel" would rank Hana Sentinel below anything with "sentinel" in position 0, which is not what I want.
Results are capped at 20. When the query is empty, the palette shows the first 20 items in insertion order, which happens to be the four pages and then the section anchors, which happens to be a decent site map.
The dynamic-import boundary
The palette body pulls in Fuse (about 15 KB min+gzip) and its own React tree. I do not want that on the initial paint path of the home page, so palette-mount.tsx is a shell component that owns the shortcut listeners and mounts the real palette only after the first open:
const CommandPalette = dynamic(
() => import("@/components/command-palette").then((m) => ({
default: m.CommandPalette,
})),
{ ssr: false, loading: () => null },
);The shell is a tiny useState(hasOpened) plus a keyboard listener for Cmd+K and /. When it fires, it flips hasOpened and dispatches an open-palette custom event. Only then does Next.js fetch the palette chunk.
There is a handoff problem this creates. Once CommandPalette mounts, it registers its own Cmd+K listener, and the shell would race with it on every subsequent press. The shell handles this by returning early: if (hasOpened) return; inside the effect. Once the palette body has hydrated once, the shell never re-attaches listeners, and the body owns the keyboard for the rest of the session.
Focus trap and scroll lock
The palette is a real modal, so it does modal things. On open:
- Save
document.activeElementinto a ref so it can be restored on close (WCAG 2.4.3 Focus Order). - Reset the query and the active index.
- Focus the input on the next animation frame (immediate
.focus()fights the mount). - Set
document.body.style.overflow = "hidden"and restore the prior value in the cleanup.
The focus trap is a focusout listener on the modal container. When focus leaves an element inside the modal, e.relatedTarget is either null (focus went to browser chrome) or an HTMLElement outside the modal (Tab hit a background link). Both cases mean the trap snaps focus back to the input, deferred by one frame so the browser's own focus move settles first. Elements still inside the modal fall through and get to keep focus.
Close paths are threefold: Escape from the body's keydown listener, click on the backdrop from onMouseDown when the target equals the currentTarget, or Enter to commit an item. All three call setOpen(false), which triggers the cleanup effect, which restores overflow and focus.
Arrow keys and Enter
The input has its own onKeyDown handler for navigation:
- Arrow down cycles
activeIdxforward modresults.length. - Arrow up cycles backward, with
+ results.lengthto keep the modulo positive. - Enter reads
results[activeIdx]and callscommit, which eitherrouter.pushes (internal) orwindow.opens withnoopener,noreferrer(external).
A second effect scrolls the active row into view with scrollIntoView({ block: "nearest" }) whenever activeIdx changes. "nearest" is important, "center" would jitter the list on every keystroke.
The status line nobody reads
The bottom of the palette shows {results.length} of {items.length} inside a role="status" with aria-live="polite" and aria-atomic="true". Screen readers announce the count on every keystroke, throttled by the browser's own polite-region debounce. Sighted users will never notice it, and that is fine. It exists so that when the count drops to zero, a screen reader user hears "0 of 79" instead of falling into a silent list. The empty-state also renders a text row (No matches for "...") that visually confirms the same thing.
The trailing ellipsis on that empty-state string is the single non-ASCII character in the whole component. Everything else, including the "esc" kbd hint, is ASCII, which matters because I have burned an hour before chasing a curly-quote glitch that snuck into a JSX string literal.
What I would add next
Two things sit in a "someday" file. The first is recent-history, a top-of-list bucket for the last five items you actually clicked, kept in localStorage. Right now the empty-query state is deterministic (pages, then sections), which is defensible for a first-time visitor and useless for anyone who comes back for the same post.
The second is per-item shortcut hints. Typing "g b" could go to the blog page without needing to open the palette. That would require its own listener in palette-mount.tsx, and a tiny prefix trie over a hand-picked set of items. Not worth writing until I catch myself wanting it more than once.
The palette is the piece of the site that I use most, on my own site, when I forget where I put something. That is the honest test.