Library / Tutorial

Command palette,
step by step.

A ⌘K command palette that is the hero of its page. One surface is both the search pill and the palette: press it and its width, height, top and corner radius, each on its own spring, grow it into a list. Type and the rows re-filter with fuzzy matching and lit letters; the arrow keys slide one selection between rows and stretch it as it goes; a giant word rolls to the name of the row under it; Enter hits the row and the palette collapses into the pill with a ring, sparks and a bump of the whole scene. Six stages, each a live page you can use.

6Stages
3Looks
0Libraries

This is the whole build of Command palette, in the order it was written. Each stage below is a live page: press the pill (or ⌘K, or Ctrl+K), type, use the arrow keys and press Enter. The code under each heading is only what that stage adds; the finished source is at the end.

Three files do the work from the start: index.html for the page and the palette, style.css for the colour tokens and the drawing, and app.js for the behaviour. From stage 4 there is a fourth, spring.js. Everything is simulated in the page; choosing a result opens nothing and sends nothing.

1 · One surface, every colour a token

The first stage is the whole page in a 432 × 768 frame that app.js scales to fit the screen: the title on top, and under it one box, .pal-wrap. That box is both the search pill and the palette. Inside it, .pal clips and rounds it, and holds two faces laid out at their own fixed size: a real <button> (the closed pill) and the role="dialog" (the open palette). A search field sits at the top of the dialog.

index.html — the surface: the pill, and the dialog's search field
<div class="pal-wrap">
  <div class="pal">
    <!-- 1 · CLOSED: the trigger. A real button; it announces itself as opening a dialog -->
    <button class="trigger" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="palette" aria-keyshortcuts="Control+K Meta+K">
      <svg class="t-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4"/></svg>
      <span class="t-label">Search or jump to…</span>
      <span class="t-keys" aria-hidden="true"><kbd class="kc mod">⌘</kbd><kbd class="kc">K</kbd></span>
    </button>

    <!-- 2 · OPEN: the dialog. Combobox input + listbox of grouped options; focus stays in the input -->
    <div class="dialog" id="palette" role="dialog" aria-modal="true" aria-label="Command palette" inert>
      <div class="field">
        <svg class="f-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4"/></svg>
        <input class="q" type="text" role="combobox" aria-expanded="false" aria-controls="results" aria-autocomplete="list" aria-haspopup="listbox"
               aria-label="Search or jump to" placeholder="Search or jump to…" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
        <button class="close" type="button" aria-label="Close palette (Escape)">esc</button>
      </div>

The list is a role="listbox" of four role="group" blocks, each labelled by its header, and each row is a role="option". At this stage the rows are written by hand, 34 px apart, and the one <i class="sel"> element sits behind the first of them. It is a single element: later it is the thing that moves.

index.html — the list, and the first group
<div class="rows" id="results" role="listbox" aria-label="Results">
  <i class="sel" aria-hidden="true" style="opacity: 1; transform: translateY(24px)"></i>
  <div class="grp" role="group" aria-labelledby="gh-recent">
    <div class="ghd" id="gh-recent" style="transform: translateY(4px)">Recent</div>
    <div class="row is-sel" id="opt-pricing" role="option" aria-selected="true" style="transform: translateY(24px)"><span class="ic ic-1" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3v18M16 7.5c-1-1-2.4-1.5-4-1.5-2.2 0-4 1-4 2.8 0 4 8 1.8 8 5.7 0 1.8-1.8 2.8-4 2.8-1.7 0-3.2-.6-4.2-1.7"/></svg></span><span class="lb">Pricing</span><span class="hn">Plans &amp; billing</span><span class="ks" aria-hidden="true"><kbd class="kc">G</kbd><kbd class="kc">P</kbd></span></div>
    <div class="row" id="opt-roadmap" role="option" aria-selected="false" style="transform: translateY(58px)"><span class="ic ic-1" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2zM9 4v14M15 6v14"/></svg></span><span class="lb">Roadmap</span><span class="hn">What's next</span><span class="ks" aria-hidden="true"><kbd class="kc">G</kbd><kbd class="kc">R</kbd></span></div>
  </div>

Every colour comes from a CSS variable, in three layers. At the bottom are the primitives: raw values with no meaning. Night has an ink-blue ramp, an ice ramp (the light), a gold (the neon) and five accent hues.

style.css — the primitives
:root {
  /* night: ink blue, ice light, gold neon */
  --nb-975: #04071A; --nb-950: #070C26; --nb-900: #0B1236; --nb-850: #101A4A; --nb-700: #24379A; --nb-500: #4C69E0;
  --ic-50: #F4F7FF; --ic-100: #E3EAFF; --ic-300: #B6C5FB; --ic-400: #94A9F2;
  --go-300: #FFE08A; --go-400: #FFCB57; --go-500: #FFB02E;
  --nx-1: #7DA2FF; --nx-2: #A98BFF; --nx-3: #5EEAD4; --nx-4: #FF8FAB; --nx-5: #FDBA74;
  --white: #fff; --black: #000;

  --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
  --serif: 'Instrument Serif', 'Times New Roman', serif;
  --row-h: 34px; --hd-h: 20px; --field-h: 50px;     /* app.js uses the same numbers */
}

On top are the semantic tokens: what a colour is for. The panel, the selection, the keycaps, the icon tiles and the pill each get their own names, and they are set on [data-look].

style.css — the semantic tokens of the night look
[data-look="night"] {
  --page: var(--nb-950);
  --field-bg: radial-gradient(ellipse 85% 50% at 50% 46%, var(--nb-850) 0%, var(--nb-900) 45%, var(--nb-975) 100%);
  --on-field: var(--ic-100); --on-field-muted: var(--ic-400); --chip: var(--go-400); --on-chip: var(--nb-975); --em: linear-gradient(180deg, var(--go-300), var(--go-500)); --scrim: color-mix(in srgb, var(--nb-975) 58%, transparent);
  --panel: linear-gradient(180deg, color-mix(in srgb, var(--nb-850) 94%, var(--nb-700)) 0%, var(--nb-900) 100%); --panel-edge: color-mix(in srgb, var(--ic-400) 38%, transparent);
  --panel-shadow: 0 40px 70px -24px color-mix(in srgb, var(--black) 85%, transparent);
  --panel-r: 22px; --ink: var(--ic-50); --ink-muted: var(--ic-300); --ink-faint: var(--ic-400); --rule: color-mix(in srgb, var(--ic-400) 18%, transparent);
  --accent: var(--go-400); --focus: var(--go-400); --caret: var(--go-400);
  --sel: linear-gradient(90deg, color-mix(in srgb, var(--ic-400) 26%, transparent), color-mix(in srgb, var(--ic-400) 7%, transparent)); --sel-edge: color-mix(in srgb, var(--go-400) 78%, transparent); --sel-shadow: transparent;
  --on-sel: var(--white); --on-sel-muted: var(--ic-100);
  --ic-bg-1: color-mix(in srgb, var(--nx-1) 20%, transparent); --ic-bg-2: color-mix(in srgb, var(--nx-2) 22%, transparent); --ic-bg-3: color-mix(in srgb, var(--go-400) 20%, transparent); --ic-ink: var(--ic-50);
  --av-1: var(--nx-3); --av-2: var(--nx-4); --av-3: var(--nx-2); --av-4: var(--nx-5); --av-5: var(--nx-1); --on-av: var(--nb-975);
  --match-bg: transparent; --on-match: var(--go-300);
  --key: linear-gradient(180deg, color-mix(in srgb, var(--ic-300) 20%, transparent), color-mix(in srgb, var(--ic-300) 6%, transparent)); --key-edge: color-mix(in srgb, var(--ic-300) 38%, transparent); --key-shadow: transparent; --on-key: var(--ic-100); --on-key-sel: var(--white);
  --trig: linear-gradient(180deg, var(--nb-850), var(--nb-900)); --trig-edge: color-mix(in srgb, var(--ic-300) 48%, transparent);
  --trig-shadow: 0 0 80px -14px color-mix(in srgb, var(--nb-500) 70%, transparent), 0 24px 36px -18px color-mix(in srgb, var(--black) 80%, transparent);
  --on-trig: var(--ic-50); --on-trig-muted: var(--ic-300);
}

Each component then names the tokens it uses, and its rules read only those names. Changing a look never touches a rule.

style.css — component tokens
.pal     { --pn-bg: var(--panel); --pn-edge: var(--panel-edge); --pn-shadow: var(--panel-shadow); --pn-ink: var(--ink); --pn-muted: var(--ink-muted); --pn-faint: var(--ink-faint);
           --pn-rule: var(--rule); --pn-accent: var(--accent); --pn-focus: var(--focus); --pn-caret: var(--caret); }
.trigger { --tg-bg: var(--trig); --tg-edge: var(--trig-edge); --tg-shadow: var(--trig-shadow); --tg-ink: var(--on-trig); --tg-muted: var(--on-trig-muted); --tg-focus: var(--focus); }
.rows    { --rw-sel: var(--sel); --rw-sel-edge: var(--sel-edge); --rw-sel-shadow: var(--sel-shadow); --rw-on-sel: var(--on-sel); --rw-on-sel-muted: var(--on-sel-muted);
           --rw-ic-1: var(--ic-bg-1); --rw-ic-2: var(--ic-bg-2); --rw-ic-3: var(--ic-bg-3); --rw-ic-ink: var(--ic-ink);
           --rw-av-1: var(--av-1); --rw-av-2: var(--av-2); --rw-av-3: var(--av-3); --rw-av-4: var(--av-4); --rw-av-5: var(--av-5); --rw-on-av: var(--on-av);
           --rw-match-bg: var(--match-bg); --rw-on-match: var(--on-match); }

The closed pill is 340 × 64 at the middle of the frame. The open palette is 392 wide, starts higher up, and is as tall as its rows (412 px for the eight default rows). In this stage the two boxes are just two sets of numbers, and app.js switches between them with one attribute.

style.css — the closed box, and the open one
.pal-wrap { position: absolute; left: 46px; top: 366px; width: 340px; height: 64px; }
.pal { position: absolute; inset: 0; overflow: hidden; border-radius: var(--r, 32px); background: var(--pn-bg); box-shadow: inset 0 0 0 1px var(--pn-edge), var(--pn-shadow); color: var(--pn-ink); font-family: var(--f-ui); }
style.css — the open box, jumped to
/* the open state, stages 1–3: the same box, jumped to its other size (stage 4 springs it instead) */
.pal-wrap[data-open] { left: 20px; top: 226px; width: 392px; height: var(--h, 412px); }
.pal-wrap[data-open] .pal { --r: 22px; }
.pal-wrap[data-open] .trigger { opacity: 0; }
.pal-wrap[data-open] .dialog { opacity: 1; }
.pal-wrap[data-open] ~ :is(.legend, .sim) { opacity: 0; }
app.js — open and close
const wrap = $('.pal-wrap'), trigger = $('.trigger'), dialog = $('.dialog'), input = $('.q'), closeBtn = $('.close');
function setOpen(on) {
  wrap.toggleAttribute('data-open', on);                           // style.css reads this attribute
  trigger.setAttribute('aria-expanded', String(on)); input.setAttribute('aria-expanded', String(on));
  dialog.inert = !on; trigger.inert = on;                          // only the visible face can be reached
  (on ? input : trigger).focus({ preventScroll: true });
}
trigger.addEventListener('click', () => setOpen(true));
closeBtn.addEventListener('click', () => setOpen(false));

inert keeps whichever face is hidden out of the Tab order and away from screen readers, and the pill is a real button, so it works with a finger, a mouse or the Enter key.

Stage 1 · the pill and the open palette, both staticOpen ↗

2 · The data and the fuzzy search

The rows are not written by hand any more. A list of 18 items in four groups (Recent, Pages, Actions, People) is the whole content. Each item has a label, a hint, the shortcut keys, an icon, a short name and some extra words the search also knows. It is simulated; a real site would build this list from its router and its people directory.

app.js — the data (the first two of 18 items)
// label, hint (second text), keys (the shortcut chips), short (the giant word), kw (extra words the search also knows)
const ITEMS = [
  { id: 'pricing', g: 'recent', label: 'Pricing', hint: 'Plans & billing', keys: ['G', 'P'], icon: 'tag', short: 'Pricing', kw: 'cost plans billing price' },
  { id: 'roadmap', g: 'recent', label: 'Roadmap', hint: "What's next", keys: ['G', 'R'], icon: 'map', short: 'Roadmap', kw: 'future plan' },

The search is written from scratch. A contiguous hit scores highest. Otherwise it looks for a subsequence, rewarding letters that start a word and letters that follow each other, and skipping at most two letters in a gap. It returns the score and which letters matched, so the page can light them up.

app.js — fuzzy()
// fuzzy search: a contiguous hit scores highest; else a subsequence, rewarding word starts and runs of consecutive letters.
// Returns { score, idx } (the matched letter positions, for highlighting) or null.
function fuzzy(q, text) {
  const t = text.toLowerCase();
  const start = (i) => i === 0 || !/[a-z0-9]/.test(t[i - 1]);
  let best = null;
  const sub = t.indexOf(q);
  if (sub !== -1) {
    let at = sub;
    for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) if (start(i)) { at = i; break; }
    best = { score: 100 - at * 1.5 + (start(at) ? 24 : 0), idx: Array.from({ length: q.length }, (_, i) => at + i) };
  }
  const flat = q.replace(/\s+/g, '');
  if (flat.length > 1) {
    for (let s = t.indexOf(flat[0]); s !== -1; s = t.indexOf(flat[0], s + 1)) {
      let pos = s, score = 10 + (start(s) ? 14 : 0) - s * 0.6, ok = true;
      const idx = [s];
      for (let k = 1; k < flat.length; k++) {
        let n = -1;
        for (let j = pos + 1; j < t.length; j++) if (t[j] === flat[k] && (j === pos + 1 || start(j))) { n = j; break; }   // a run or a word start
        if (n === -1) for (let j = pos + 1; j <= pos + 3 && j < t.length; j++) if (t[j] === flat[k]) { n = j; break; }   // else a gap of at most two letters
        if (n === -1) { ok = false; break; }
        score += 10 + (n === pos + 1 ? 9 : -(n - pos - 1) * 0.8) + (start(n) ? 10 : 0);
        idx.push(n); pos = n;
      }
      if (ok && (!best || score > best.score)) best = { score, idx };
    }
  }
  return best;
}

A row's label is matched fuzzily; its hint and its keywords only as a plain substring, which scores lower and highlights nothing (so typing "billing" finds Pricing and Invoices). With an empty query each group shows its top two rows. With a query you get the best eight, and groups are ordered by their best row, so nothing ever needs to scroll.

app.js — one item, and the view for a query
// one item against the query: the label (fuzzy, highlighted), then its hint and keywords (a plain substring, weaker, no highlight)
function match(it, q) {
  const a = fuzzy(q, it.label);
  if (a) return { it, score: a.score, idx: a.idx };
  return `${it.hint} ${it.kw}`.toLowerCase().includes(q) ? { it, score: 30, idx: [] } : null;     // the hint and keywords: whole-text hits only
}
// the view for a query: groups in order, each with its rows. Empty query → top 2 per group; else the best 8 by score,
// groups ordered by their best row.
function viewFor(q) {
  q = q.trim().toLowerCase();
  if (!q) return GROUPS.map((g) => ({ g, rows: ITEMS.filter((i) => i.g === g.id).slice(0, PER_GROUP).map((it) => ({ it, idx: [] })) }));
  const hits = ITEMS.map((it) => match(it, q)).filter(Boolean).sort((a, b) => b.score - a.score).slice(0, MAX_RESULTS);
  const out = [];
  for (const h of hits) {
    let v = out.find((x) => x.g.id === h.it.g);
    if (!v) { v = { g: GROUPS.find((g) => g.id === h.it.g), rows: [] }; out.push(v); }
    v.rows.push(h);
  }
  return out;                                                      // already best-first: groups appear in order of their best row
}

The rows are built once, then shown, hidden and moved. The highlight is a <mark> around each run of matched letters, and esc() keeps a label's characters from being read as markup.

app.js — the groups and the rows, built once
const groups = GROUPS.map((g) => {
  const el = document.createElement('div');
  el.className = 'grp'; el.setAttribute('role', 'group'); el.setAttribute('aria-labelledby', `gh-${g.id}`);
  el.innerHTML = `<div class="ghd" id="gh-${g.id}">${g.name}</div>`;
  rowsEl.append(el);
  return { ...g, el, hd: el.firstChild, shown: false, want: 0, at: null, yT: 0 };
});
const items = ITEMS.map((it) => {
  const g = groups.find((x) => x.id === it.g);
  const el = document.createElement('div');
  el.className = 'row'; el.id = `opt-${it.id}`; el.setAttribute('role', 'option'); el.setAttribute('aria-selected', 'false'); el.hidden = true;
  const icon = it.icon
    ? `<span class="ic ${GROUP_ICON[it.g]}" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="${ICON[it.icon]}"/></svg></span>`
    : `<span class="ic av-${(avN++ % 5) + 1}" aria-hidden="true"><b>${it.initials}</b></span>`;
  el.innerHTML = `${icon}<span class="lb"></span><span class="hn">${esc(it.hint)}</span><span class="ks" aria-hidden="true">${it.keys.map((k) => `<kbd class="kc">${k}</kbd>`).join('')}</span>`;
  g.el.append(el);
  return { ...it, el, lb: $('.lb', el), grp: g, shown: false, want: 0, at: null, yT: 0, idx: [] };
});
app.js — the matched letters
// the matched letters, wrapped in <mark> (a run of consecutive letters is one mark)
function labelHTML(text, idx) {
  if (!idx.length) return esc(text);
  const on = new Set(idx);
  let out = '', open = false;
  for (let i = 0; i < text.length; i++) {
    if (on.has(i) && !open) { out += '<mark>'; open = true; }
    if (!on.has(i) && open) { out += '</mark>'; open = false; }
    out += esc(text[i]);
  }
  return out + (open ? '</mark>' : '');
}

Showing a view means placing each header (20 px) and row (34 px) and setting the palette's height from the number of rows. Everything lands at once; the next stages make it move.

app.js — placing the view
function applyView(view) {
  flat = [];
  const inRow = new Set(), inGroup = new Set();
  let y = TOP_PAD;
  for (const { g, rows } of view) {
    const go = groups.find((x) => x.id === g.id);
    inGroup.add(go);
    go.hd.style.transform = `translateY(${y}px)`; y += HD_H;
    for (const r of rows) {
      const it = items.find((x) => x.id === r.it.id);
      it.el.style.transform = `translateY(${y}px)`; it.yT = y; y += ROW_H;
      it.idx = r.idx || [];
      inRow.add(it);
      flat.push({ it });
    }
  }
  const listH = flat.length ? y + BOTTOM_PAD : 118;
  hOpen = FIELD_H + listH;
  wrap.style.setProperty('--h', `${hOpen}px`);                     // style.css gives the open box this height
  rowsEl.style.height = `${listH}px`;
app.js — a keystroke
function setQuery(q) {
  query = q;
  applyView(viewFor(q));
  const n = flat.length;
  setActive(0);
  emptyEl.querySelector('.e-sub').textContent = n ? '' : `Nothing for “${q.trim().length > 14 ? q.trim().slice(0, 13) + '…' : q.trim()}”. Try “pricing”.`;
  emptyEl.hidden = n > 0;
}

The "No results" state is a friendly panel with an icon and a line that repeats what you typed. In the night look the matches are a thin glowing underline:

style.css — the match highlight
/* matches: a thin neon underline that glows */
[data-look="night"] .row mark { font-weight: 700; text-decoration: none; background: linear-gradient(var(--go-400), var(--go-400)) 0 100% / 100% 2px no-repeat; text-shadow: 0 0 10px color-mix(in srgb, var(--go-400) 80%, transparent); box-shadow: 0 6px 10px -6px color-mix(in srgb, var(--go-400) 90%, transparent); }
Stage 2 · press the pill, then type: the list re-filters at onceOpen ↗

3 · Keyboard, focus and a combobox that says what it is

A palette is mostly a keyboard control, so this stage makes it a real combobox. The input carries role="combobox", aria-expanded, aria-controls and aria-autocomplete="list"; the pill is a button with aria-haspopup="dialog" and aria-keyshortcuts; the dialog is aria-modal. The markup was already there from stage 1. What is new is keeping it true.

index.html — the dialog and its combobox
<div class="dialog" id="palette" role="dialog" aria-modal="true" aria-label="Command palette" inert>
  <div class="field">
    <svg class="f-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4"/></svg>
    <input class="q" type="text" role="combobox" aria-expanded="false" aria-controls="results" aria-autocomplete="list" aria-haspopup="listbox"
           aria-label="Search or jump to" placeholder="Search or jump to…" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
    <button class="close" type="button" aria-label="Close palette (Escape)">esc</button>
  </div>

Focus never leaves the input. The selected row is announced through aria-activedescendant, which points at the option's id, and aria-selected follows it. Closing sends focus back to wherever it came from.

app.js — open, close, and where focus goes
function openPalette() {
  if (isOpen) return;
  isOpen = true;
  opener = document.activeElement;
  endDest();
  wrap.setAttribute('data-open', '');
  trigger.setAttribute('aria-expanded', 'true'); input.setAttribute('aria-expanded', 'true');
  dialog.inert = false; trigger.inert = true;
  input.value = '';
  setQuery('');
  input.focus({ preventScroll: true });
}
function closePalette() {
  if (!isOpen) return;
  isOpen = false;
  wrap.removeAttribute('data-open');
  trigger.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-expanded', 'false');
  dialog.inert = true; trigger.inert = false;
  (opener && opener.isConnected && opener !== document.body ? opener : trigger).focus({ preventScroll: true });   // focus goes back where it came from
}
app.js — the selection
// the selection: one element, moved to the chosen row
function setActive(i) {
  active = flat.length ? clamp(i, 0, flat.length - 1) : 0;
  flat.forEach((f, k) => f.it.el.classList.toggle('is-sel', k === active));
  flat.forEach((f, k) => f.it.el.setAttribute('aria-selected', k === active ? 'true' : 'false'));
  const cur = flat[active];
  if (cur) input.setAttribute('aria-activedescendant', cur.it.el.id); else input.removeAttribute('aria-activedescendant');
  selEl.style.opacity = flat.length ? '1' : '0';
  if (flat.length) selEl.style.transform = `translateY(${flat[active].it.yT}px)`;
}

The keys are the combobox pattern: ArrowUp and ArrowDown move (and wrap), Home and End jump to the first and last row, PageUp and PageDown move four, Enter chooses, Escape closes. Tab cycles between the input and the esc button, so focus can not leave the dialog; a focusin listener catches focus that arrives from elsewhere. ⌘K or Ctrl+K toggles the palette from anywhere on the page.

app.js — the keyboard
// ---- keyboard ----
addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); isOpen ? closePalette() : openPalette(); }
});
dialog.addEventListener('keydown', (e) => {
  if (e.isComposing) return;
  const n = flat.length, k = e.key;
  if (k === 'Escape') { e.preventDefault(); closePalette(); }
  else if (k === 'Tab') {                                          // the focus trap: input ↔ close button
    e.preventDefault();
    (document.activeElement === input ? closeBtn : input).focus({ preventScroll: true });
  } else if (document.activeElement !== input) return;
  else if (k === 'ArrowDown') { e.preventDefault(); n && setActive((active + 1) % n); }
  else if (k === 'ArrowUp') { e.preventDefault(); n && setActive((active - 1 + n) % n); }
  else if (k === 'PageDown') { e.preventDefault(); n && setActive(Math.min(n - 1, active + 4)); }
  else if (k === 'PageUp') { e.preventDefault(); n && setActive(Math.max(0, active - 4)); }
  else if ((k === 'Home' || k === 'End') && !e.shiftKey) { e.preventDefault(); n && setActive(k === 'Home' ? 0 : n - 1); }
  else if (k === 'Enter') { e.preventDefault(); launch(); }
});
addEventListener('focusin', (e) => { if (isOpen && !dialog.contains(e.target)) input.focus({ preventScroll: true }); });   // the trap, for focus that arrives from elsewhere

The pointer does the same jobs: hover moves the selection, a click chooses, and a press outside closes. Pressing a row is cancelled with preventDefault() so the input keeps focus.

app.js — the pointer
// ---- pointer: hover moves the selection, a click chooses; a press outside closes ----
listEl.addEventListener('pointermove', (e) => {
  const row = e.target.closest('.row');
  if (!row || !isOpen) return;
  const i = flat.findIndex((f) => f.it.el === row);
  if (i !== -1 && i !== active) setActive(i);
});
rowsEl.addEventListener('pointerdown', (e) => { if (e.target.closest('.row')) e.preventDefault(); });   // keep the focus in the input
rowsEl.addEventListener('click', (e) => {
  const row = e.target.closest('.row');
  const i = flat.findIndex((f) => f.it.el === row);
  if (i !== -1) { setActive(i); launch(); }
});
addEventListener('pointerdown', (e) => { if (isOpen && !wrap.contains(e.target)) closePalette(); });

For people who can not see the list, there is one polite live region. It says "8 results", "1 result" or "No results" after a keystroke, and what was chosen after Enter. The region is cleared first, so the same sentence is announced again.

app.js — one announcement per change
let sayTimer = 0;
function say(text, ms = 30) { clearTimeout(sayTimer); sayTimer = setTimeout(() => { live.textContent = ''; setTimeout(() => { live.textContent = text; }, 30); }, ms); }
const countText = (n) => (n ? `${n} result${n === 1 ? '' : 's'}` : 'No results');
index.html — the live region
<!-- one polite announcement per change: "8 results", "No results", "Opening Pricing (simulated)" -->
<p class="sr-only live" role="status" aria-live="polite" aria-atomic="true"></p>

Enter chooses. In the demo nothing is opened: the pill names where it would have gone ("Opening: Pricing →" for a page, "Running: Dark mode →" for an action) and goes back after 2.7 seconds.

app.js — the destination, and Enter
const tLabel = $('.t-label');
function showDest(it) {
  dest = it;
  tLabel.textContent = `${it.verb || 'Opening'}: ${it.short} →`;
  trigger.classList.add('is-dest');
  simEl.textContent = `Simulated: “${it.label}” was not really opened.`;
}
function endDest() {
  clearTimeout(destTimer);
  if (!dest) return;
  dest = null;
  tLabel.textContent = 'Search or jump to…';
  trigger.classList.remove('is-dest');
  simEl.textContent = 'Simulated. Picking a result opens nothing.';
}
function launch(it) {
  if (!isOpen || !flat.length) return;
  it = it || flat[active].it;
  say(`${it.verb || 'Opening'} ${it.label} (simulated)`, 40);
  showDest(it); closePalette();
  destTimer = setTimeout(() => { if (!isOpen) endDest(); }, 2700);
}
Stage 3 · press the pill, type, use the arrows, Enter and EscapeOpen ↗

4 · Springs: one surface, one selection

Now it moves. spring.js is a mass on a spring with a damper, stepped in 1/240 s slices, so it behaves the same on a 60 Hz and a 120 Hz screen. Changing the target never moves the value: an interrupted animation just bends towards the new target, which is why the palette feels alive when you hammer it.

spring.js
// A spring, written from scratch: a mass on a spring with a damper.
//   acceleration = stiffness × (target − value) − damping × velocity
// It is stepped in small fixed slices (1/240 s), so it moves the same on a 60 Hz and a 120 Hz screen.
// Changing the target never moves the value: an interrupted animation just bends towards the new target.
class Spring {
  constructor(value = 0, { stiffness = 170, damping = 26 } = {}) {
    this.value = value;
    this.target = value;
    this.velocity = 0;
    this.stiffness = stiffness;
    this.damping = damping;
  }

  set(options) { Object.assign(this, options); return this; }

  step(dt) {
    const slice = 1 / 240;
    for (let t = dt; t > 0; t -= slice) {
      const h = Math.min(slice, t);
      const acceleration = this.stiffness * (this.target - this.value) - this.damping * this.velocity;
      this.velocity += acceleration * h;
      this.value += this.velocity * h;
    }
    return this.value;
  }

  resting(epsilon = 0.01) {
    return Math.abs(this.target - this.value) < epsilon && Math.abs(this.velocity) < epsilon * 10;
  }
}

The pill becomes the palette because four numbers each get their own spring: width, height, top and corner radius. Width and height overshoot a little (they open at about ζ 0.6), the top barely does, so the box does not wobble sideways. On the way back the springs are nearly critical, so the pill never dips below its own size.

app.js — the surface's springs
const spring = (stiffness, damping) => ({ stiffness, damping });
const CLOSED = { w: 340, h: 64, top: 366, r: 32 };
const OPEN = { w: 392, top: 226, r: 22 };
const sw = new Spring(CLOSED.w, spring(300, 19));                // the surface: width, height (overshoot ≈ 9 %), top (nearly critical), radius
const sh = new Spring(CLOSED.h, spring(250, 18.5));
const stp = new Spring(CLOSED.top, spring(320, 29));
const sr = new Spring(CLOSED.r, spring(260, 22));
const p = new Spring(0, spring(240, 20));                        // 0 closed … 1 open: the faces' fades
app.js — opening and closing are just new targets
function setTargets() {
  // opening springs overshoot a little (ζ ≈ .55); closing springs are nearly critical, so the pill never dips below its size
  if (isOpen) { sw.set(spring(300, 19)); sh.set(spring(250, 18.5)); sr.set(spring(260, 22)); }
  else { sw.set(spring(300, 31)); sh.set(spring(280, 32)); sr.set(spring(260, 27)); }
  if (isOpen) { sw.target = OPEN.w; sh.target = hOpen; stp.target = OPEN.top; sr.target = OPEN.r; p.target = 1; }
  else { sw.target = CLOSED.w; sh.target = CLOSED.h; stp.target = CLOSED.top; sr.target = CLOSED.r; p.target = 0; }
}

A fifth spring, p (0 closed … 1 open), fades the two faces: the pill is gone by a quarter of the way, the dialog arrives after 28 %. Every frame writes the box, and writes only what changed.

app.js — drawing the surface
function renderSurface() {
  const w = sw.value, h = sh.value, top = stp.value, o = clamp(p.value, 0, 1);
  put(wrap, 'left', `${((432 - w) / 2).toFixed(2)}px`); put(wrap, 'top', `${top.toFixed(2)}px`);
  put(wrap, 'width', `${w.toFixed(2)}px`); put(wrap, 'height', `${h.toFixed(2)}px`);
  put(pal, 'borderRadius', `${Math.max(0, sr.value).toFixed(2)}px`);
  put(trigger, 'opacity', (1 - clamp(o * 4, 0, 1)).toFixed(3));
  put(dialog, 'opacity', clamp((o - 0.28) * 2.4, 0, 1).toFixed(3));
  promote(scrimEl, !p.resting(0.01));                               // composited only while it fades
  put(scrimEl, 'opacity', (o * 0.95).toFixed(3));
  const fade = (1 - clamp(o * 3, 0, 1)).toFixed(3);
  put(legendEl, 'opacity', fade); put(simEl, 'opacity', fade);
  put(trigger, 'pointerEvents', o > 0.5 ? 'none' : '');
}

The rows enter one after another: 34 ms apart (60 ms for a header), the first 120 ms after the surface starts to open. Each row has two springs, its pop (opacity and scale) and its y (so a row that moves when you type glides to its new place). A row that leaves shrinks fast on a stiffer spring and is then hidden.

app.js — rows enter, move and leave
const enter = (o, i, step, delay) => {                          // appear: placed at its target, popping in (staggered)
  if (!o.shown || fresh) {                                      // a fresh open: every row starts from nothing, so they all pop in
    o.shown = true; o.el.hidden = false;
    o.s.y.value = o.s.y.target = o.yT; o.s.y.velocity = 0;
    o.s.pop.value = 0; o.s.pop.velocity = 0;
  }
  o.s.y.target = o.yT;
  o.s.pop.set(spring(380, 17));
  o.want = 1;
  o.at = clock + delay + i * step;
};
const leave = (o) => {                                          // leave: fade and shrink fast, then hide
  if (o.shown && o.want) { o.want = 0; o.at = null; o.s.pop.set(spring(700, 44)); o.s.pop.target = 0; }
};
let gi = 0;
groups.forEach((go) => { if (inGroup.has(go)) enter(go, gi++, 0.06, fresh ? 0.08 : 0.02); else leave(go); });
items.forEach((it) => { if (inRow.has(it)) enter(it, inRow.get(it), 0.034, fresh ? 0.12 : 0.02); else leave(it); });

The selection is one element with two edges. Its top and bottom are two springs: the edge in the direction of travel is stiff, the other lags (520/32 against 250/23), so the selection stretches while it moves and then settles. A specular light follows it on slower springs and overshoots a little.

app.js — the selection runs to a row
function setActive(i, { dir = 0, instant = false, hover = false } = {}) {
  active = flat.length ? clamp(i, 0, flat.length - 1) : 0;
  if (dir) lastDir = dir;
  flat.forEach((f, k) => { f.it.el.setAttribute('aria-selected', k === active ? 'true' : 'false'); f.it.el.classList.toggle('is-sel', k === active); });
  const cur = flat[active];
  if (cur) input.setAttribute('aria-activedescendant', cur.it.el.id); else input.removeAttribute('aria-activedescendant');
  if (cur) {
    const y = cur.it.yT;
    const lead = spring(...SEL.lead), trail = spring(...SEL.trail);
    if (dir >= 0) { selBot.set(lead); selTop.set(trail); } else { selTop.set(lead); selBot.set(trail); }
    selTop.target = y; selBot.target = y + ROW_H;
    spotY.target = y + ROW_H / 2;
    if (!hover) spotX.target = 150;
    if (instant) { snap(selTop, selBot, spotY); spotX.value = spotX.target; }
  }
  wake();
}
app.js — drawing it
function renderSelection() {
  const top = selTop.value, bot = selBot.value, sy = Math.max(0.5, (bot - top) / ROW_H);   // the two edges apart = the stretch
  const on = clamp(selOn.value, 0, 1);
  const h = 0;
  const busy = !(selTop.resting(0.05) && selBot.resting(0.05) && selOn.resting(0.01));
  promote(selEl, busy); promote(spotEl, busy || !(spotX.resting(0.1) && spotY.resting(0.1)));
  put(selEl, 'opacity', on.toFixed(3));
  put(selEl, 'transform', `translateY(${top.toFixed(2)}px) scale(${(1 - 0.05 * h).toFixed(4)}, ${(sy * (1 + 0.14 * h)).toFixed(4)})`);
  put(spotEl, 'transform', `translate(${spotX.value.toFixed(1)}px, ${spotY.value.toFixed(1)}px)`);
  put(spotEl, 'opacity', clamp(0.85 * on + h * 0.5, 0, 1).toFixed(3));
}

One loop drives everything and sleeps when every spring is at rest. Timed starts (the stagger) are a small queue checked each frame.

app.js — the loop
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000)); last = now;
  clock += dt;
  for (let i = queue.length - 1; i >= 0; i--) if (queue[i].at <= clock) queue.splice(i, 1)[0].fn();
  // scheduled starts: the rows and group headers pop in one after another
  for (const o of [...groups, ...items]) if (o.at !== null && o.at <= clock) { o.at = null; o.s.pop.target = o.want; }
  springs().forEach((s) => s.step(dt));
  if (pal.classList.contains('is-closing') && p.resting(0.01) && sh.resting(0.2) && !isOpen) pal.classList.remove('is-closing');
  draw(dt);
  const still = springs().every((s) => s.resting(0.002)) && !queue.length && [...groups, ...items].every((o) => o.at === null);
  if (!still) requestAnimationFrame(frame); else running = false;
}
Stage 4 · the pill springs open, the rows pop in, the selection stretchesOpen ↗

5 · The giant word, the impact and the shake

Above the surface there is one huge word. It reads "Jump to", rolls to the name of the row under the selection as you move, shrinks and rides up with the morph, and after Enter stays on the destination. Two layers swap on a spring: the old word leaves up or down depending on the direction of travel, the new one arrives from the other side. A long name is measured and scaled to fit the frame.

index.html — the word
<!-- the giant word: "Jump to", then the name of the row under the cursor, then the destination -->
<p class="word" aria-hidden="true"><span class="w wa">Jump to</span><span class="w wb"></span></p>
app.js — the word, and its roll
function wordWanted() {
  if (dest) return dest.short;
  if (isOpen) return flat.length ? flat[active].it.short : 'No match';
  return 'Jump to';
}
function fitOf(el) {                                               // shrink a long name to the frame (measured, not guessed)
  const keep = el.style.transform; el.style.transform = 'none';       // measure unscaled
  const r = document.createRange(); r.selectNodeContents(el);
  const w = r.getBoundingClientRect().width / scaleNow();
  el.style.transform = keep;
  return w > 292 ? 292 / w : 1;                                       // 292 × 1.34 (the closed scale) still fits the 432 frame
}
function syncWord(dir = lastDir) {
  const want = wordWanted();
  if (want === wordNext) return;
  if (wq.target === 1 && !wq.resting(0.02)) finishWord();           // an earlier roll still running: land it first
  wordNext = want; wDir = dir >= 0 ? 1 : -1;
  words[1].textContent = want; wFit[1] = fitOf(words[1]);
  wq.value = 0; wq.velocity = 0; wq.target = 1;
}
function finishWord() {
  wordShown = wordNext; words[0].textContent = wordShown; wFit[0] = fitOf(words[0]);
  words[1].textContent = ''; wq.value = 0; wq.velocity = 0; wq.target = 0;
}
app.js — the roll, drawn
function renderWord() {
  const q = clamp(wq.value, 0, 1.05), moving = wq.target === 1;
  const [a, b] = words;
  if (!moving) { put(a, 'opacity', '1'); put(a, 'transform', `scale(${wFit[0].toFixed(4)})`); put(b, 'opacity', '0'); return; }
  const y = wDir * 50;
  put(a, 'opacity', clamp(1 - q * 1.5, 0, 1).toFixed(3)); put(a, 'transform', `translateY(${(-y * q).toFixed(2)}px) scale(${(wFit[0] * (1 - 0.12 * q)).toFixed(4)})`);
  put(b, 'opacity', clamp(q * 1.5, 0, 1).toFixed(3)); put(b, 'transform', `translateY(${(y * (1 - q)).toFixed(2)}px) scale(${(wFit[1] * (0.88 + 0.12 * q)).toFixed(4)})`);
}

Enter is the impact. The selected row is hit (a kick on a spring), 200 ms later the palette collapses into the pill, which names the destination, and at 500 ms two rings, fourteen sparks and a light flare go off while the whole scene jolts. The sparks are not springs: each has an angle and a speed, and the frame computes its position from the age of the impact. The pill returns to normal 3.2 s after Enter.

app.js — Enter
function launch(it) {
  if (!isOpen || launching || !flat.length) return;
  it = it || flat[active].it;
  launching = true;
  hit.velocity += 9;                                                              // the selection is hit
  say(`${it.verb || 'Opening'} ${it.label} (simulated)`, 40);
  later(0.2, () => { showDest(it); closePalette(); });                            // the panel collapses back into the pill …
  later(0.5, () => impact());                                                      // … and lands
  later(0.5 + 2.7, () => { launching = false; if (!isOpen) endDest(); });
}
app.js — where it lands
function impact() {
  const k = scaleNow(), fr = frameEl.getBoundingClientRect(), r = wrap.getBoundingClientRect();
  impX = (r.left - fr.left) / k + r.width / k / 2; impY = (r.top - fr.top) / k + r.height / k / 2;
  ringAge = 0; sparkAge = 0; flareAge = 0;
  ring.value = 0; ring.velocity = 0; ring.target = 1;
  bump.velocity += 6;
  sparks.forEach((s) => { s.v0 = s.v; });
}
app.js — fourteen sparks
const SPARKS = 14;
let sseed = 33;
const rand = () => ((sseed = (sseed * 16807) % 2147483647) / 2147483647);
const sparks = Array.from({ length: SPARKS }, (_, i) => {
  const el = document.createElement('i');
  el.className = `sp sp${(i % 4) + 1}`;
  sparkHost.append(el);
  const a = -Math.PI / 2 + (rand() - 0.5) * 3.4;                  // mostly upward, a wide fan
  return { el, a, v: 150 + rand() * 190, z: 0.7 + rand() * 0.7, spin: (rand() - 0.5) * 16, v0: 0 };
});
const GRAV = -70;                                                  // negative: the sparks float up
let ringAge = 9, sparkAge = 9, impX = 216, impY = 398, flareAge = 9;
app.js — the sparks, drawn from their age
sparks.forEach((s) => {
  promote(s.el, sparkAge < 1.1);
  const t = sparkAge, v = s.v0, life = clamp(1 - t / 1.05, 0, 1);
  if (!life || !v) { s.el.style.opacity = '0'; return; }
  const x = impX + Math.cos(s.a) * v * t, y = impY - 4 + Math.sin(s.a) * v * t + 0.5 * GRAV * t * t;
  s.el.style.opacity = Math.min(1, life * 1.8).toFixed(3);
  s.el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${(s.spin * t + s.a).toFixed(3)}rad) scale(${(s.z * (0.35 + 0.65 * life)).toFixed(3)})`;
});

The jolt is one more spring written into a CSS variable that scales the frame, and the selection squashes on the hit by the same kind of kick.

app.js — draw(), with the jolt
function draw(dt = 0) {
  html.style.setProperty('--bump', (1 + 0.04 * bump.value).toFixed(4));
  renderSurface(); renderRows(); renderSelection(); renderEmpty();
  renderWord(); renderFx(dt);
}
style.css — the frame reads it
.frame { position: relative; width: 432px; height: 768px; flex: none; transform: scale(calc(var(--k, 1) * var(--bump, 1))); }

When nothing matches, the "No results" panel does not just appear: it fades in, and gets a sideways shake by kicking one spring (shake.velocity += 340) that is read as an x offset.

app.js — the shake
if (empty) {
  emp.target = 1; selOn.target = 0;
  shake.velocity += 340;
app.js — drawing the empty state
function renderEmpty() {
  const e = clamp(emp.value, 0, 1);
  put(emptyEl, 'opacity', e.toFixed(3)); put(emptyEl, 'visibility', e > 0.01 ? 'visible' : 'hidden');
  put(emptyEl, 'transform', `translateX(${shake.value.toFixed(2)}px) scale(${(0.94 + 0.06 * e).toFixed(4)})`);
}
Stage 5 · type a few letters and press Enter; then type "zq"Open ↗

6 · The finish

The last stage adds the things that make it a finished piece: the three looks (next section), ambient art that is a pure function of a clock, a reduced-motion mode, the credits, and a look chosen before the first paint so the page never flashes the wrong one.

index.html — the look, before the first paint
<!-- before the first paint: the look (?look=catalog|night|bubble, or a|b|c), so the page never flashes the wrong one.
     Optional ?open=1 starts with the palette open, ?q=text types a query (for links and the preview scripts). -->
<script>
  (() => {
    const html = document.documentElement, q = new URLSearchParams(location.search);
    const look = { a: 'catalog', b: 'night', c: 'bubble' }[q.get('look')] || q.get('look');
    html.dataset.look = ['catalog', 'night', 'bubble'].includes(look) ? look : 'night';   // the default: the strongest look
  })();
</script>

With prefers-reduced-motion there is no morph: the surface jumps to its size and the two faces fade over 0.16 s. There is no stagger, the impact is hidden, and every spring is critically damped, so nothing overshoots.

app.js — reduced motion
// With reduced motion every spring is critically damped and the morph is replaced by a jump + a fade.
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
style.css — reduced motion
/* ---- 12. reduced motion: no morph, no stagger, no sparks; the palette fades in (app.js jumps the geometry) ---- */
@media (prefers-reduced-motion: reduce) {
  .dialog, .trigger { transition: opacity .16s linear; }
  .fx { display: none; }
  .amb * { animation: none; }
}

Each look has its own slow ambient art. It reads the time from one clock (studio.now()), so a paused clock gives the same frame every time. Night has a spotlight beam and a warm horizon that breathe; catalog has lamplight and drifting dust; bubble has floating bubbles and a bobbing sun.

app.js — the ambient art
function renderAmbient(t) {
  if (LOOK === 'catalog') {
    amb.lamp.style.transform = `translate(${(Math.sin(t * 0.21) * 20).toFixed(1)}px, ${(Math.cos(t * 0.17) * 12).toFixed(1)}px)`;
    amb.dust.forEach((d, i) => { const [x, y, dx, dy, per] = DUST[i], w = TAU / per; d.style.transform = `translate(${(x + dx * Math.sin(w * t + i)).toFixed(1)}px, ${(y + dy * Math.sin(w * 0.7 * t + i * 2)).toFixed(1)}px)`; });
  } else if (LOOK === 'night') {
    amb.beam.style.opacity = (0.8 + 0.2 * Math.sin(t * 0.7)).toFixed(3);
    amb.horizon.style.opacity = (0.8 + 0.2 * Math.sin(t * 0.9 + 1)).toFixed(3);
  } else {
    amb.bubs.forEach((b, i) => { const [x, y, dx, dy, per] = BUBS[i], w = TAU / per; b.style.transform = `translate(${(x + dx * Math.sin(w * t + i * 1.7)).toFixed(1)}px, ${(y + dy * Math.sin(w * 0.8 * t + i)).toFixed(1)}px)`; });
    amb.sun.style.transform = `translateY(${(Math.sin(t * 0.5) * 6).toFixed(1)}px)`;
  }
}
style.css — night's beam and horizon
[data-look="night"] .amb .beam { display: block; left: 56px; top: 0; width: 320px; height: 620px; clip-path: polygon(41% 0, 59% 0, 100% 100%, 0 100%); background: linear-gradient(180deg, color-mix(in srgb, var(--ic-300) 20%, transparent), color-mix(in srgb, var(--ic-300) 5%, transparent) 62%, transparent); will-change: opacity; }
[data-look="night"] .amb .horizon { display: block; left: -60px; right: -60px; bottom: -120px; height: 360px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--go-500) 22%, transparent), transparent); will-change: opacity; }

The frame ends with the tutorial link and the credits.

index.html — the footer
<p class="tutorial"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6.5h8.5V15h-2.2v-4.7l-6.6 6.6-1.6-1.6 6.6-6.6H9z"/></svg>Step-by-step tutorial <span>— domvybe.com</span></p>
<footer class="credits">
  <p class="stack">HTML <i>·</i> CSS <i>·</i> JavaScript</p>
  <p class="logo"><img class="avatar" src="avatar.png" alt=""><img class="lockup" src="codewithulvi.svg" alt="Code with Ulvi"></p>
</footer>
Stage 6 · the finished paletteOpen ↗

Three looks, one token system

The finished file has three looks: catalog, night and bubble. They share the markup, the script and the three token layers. Each look is its own primitives, one block of semantic tokens on [data-look], and the shapes it draws differently: a library card catalog with a red rubber stamp as the selection, a stage at night with a lit pane and a gold neon underline, and bubblegum with a jelly selection. The header of style.css is the map.

style.css — its header: the sections, and the token layers
/* Command palette — a ⌘K palette that springs out of a search pill. Three looks (?look=catalog|night|bubble), each a whole
   world with its own row, selection indicator, keycap and impact.

   1 tokens: primitive → semantic (per look) → component     2 stage, frame, ambient     3 the frame's words
   4 the surface, the pill, the dialog   5 field, rows, selection, keys   6 empty state   7 impact   8 footer
   9 look A · catalog   10 look B · night   11 look C · bubble   12 reduced motion

   ---- 1. TOKENS -------------------------------------------------------------------------------------------------
   1a PRIMITIVES (:root): raw, hand-tuned values with no meaning — a tonal ramp per hue, three worlds.
        catalog   --fe felt green · --cd card cream · --tx typewriter ink · --rb rubber-stamp red · --mu mustard
        night     --nb ink blue · --ic ice (the light) · --go gold (the neon underline) · --nx accent hues
        bubble    --pk pink · --lc lilac · --sk sky · --mn mint · --lm lemon · --gp grape (the outline ink)
   1b SEMANTIC TOKENS (per look, on [data-look]): what a colour is FOR.
        field   --page --field-bg --on-field --on-field-muted --chip --on-chip --em --scrim
        panel   --panel --panel-edge --panel-shadow --panel-r --ink --ink-muted --ink-faint --rule --accent --focus --caret
        rows    --sel --sel-edge --sel-shadow --on-sel --on-sel-muted --ic-bg-1/2/3 --ic-ink --av-1…5 --on-av --match-bg --on-match
        keys    --key --key-edge --key-shadow --on-key --on-key-sel
        word    --word --word-edge --word-shadow
        trigger --trig --trig-edge --trig-shadow --on-trig --on-trig-muted
        impact  --spark-1…4 --fx-ring --fx-ink
        frame   --pill --pill-edge --on-pill --on-pill-muted --pill-icon --on-pill-icon --ring --lockup
   1c COMPONENT TOKENS: each component names the semantic tokens it uses (.panel { --pn-bg … }).
   Component rules (sections 2–8) use only component or semantic tokens (plus color-mix transparency helpers). The look
   sections (9–11) shape parts with tokens too. */

Even the page behind the stage is a token of the look; app.js also gives it to the browser's theme colour.

app.js — the theme colour
document.querySelector('meta[name="theme-color"]').content = getComputedStyle(html).getPropertyValue('--page').trim() || '#0A2C20';

Here is one complete semantic set, bubble's: a candy world, a pale sticker panel in a grape outline with a hard offset shadow, and a lemon selection. The look's colours, outlines and shadows are all in this block.

style.css — the bubble set of semantic tokens
[data-look="bubble"] {
  --page: var(--pk-400);
  --field-bg: linear-gradient(165deg, var(--pk-300) 0%, var(--pk-400) 42%, var(--lc-400) 100%);
  --on-field: var(--gp-950); --on-field-muted: var(--gp-950); --chip: var(--lm-300); --on-chip: var(--gp-900); --em: linear-gradient(180deg, var(--gp-700), var(--gp-700)); --scrim: color-mix(in srgb, var(--gp-800) 14%, transparent);
  --panel: var(--pk-100); --panel-edge: var(--gp-900); --panel-shadow: 6px 7px 0 0 var(--gp-900);
  --panel-r: 28px; --ink: var(--gp-900); --ink-muted: var(--gp-600); --ink-faint: var(--gp-600); --rule: color-mix(in srgb, var(--gp-900) 12%, transparent);
  --accent: var(--gp-700); --focus: var(--gp-700); --caret: var(--pk-500);
  --sel: var(--lm-300); --sel-edge: var(--gp-900); --sel-shadow: var(--gp-900);
  --on-sel: var(--gp-900); --on-sel-muted: var(--gp-800);
  --ic-bg-1: var(--lm-300); --ic-bg-2: var(--sk-300); --ic-bg-3: var(--mn-300); --ic-ink: var(--gp-900);
  --av-1: var(--mn-300); --av-2: var(--pk-300); --av-3: var(--sk-300); --av-4: var(--lm-300); --av-5: var(--lc-300); --on-av: var(--gp-900);
  --match-bg: var(--pk-300); --on-match: var(--gp-900);
  --key: var(--white); --key-edge: var(--gp-900); --key-shadow: var(--gp-900); --on-key: var(--gp-900); --on-key-sel: var(--gp-900);
  --word: var(--white); --word-edge: var(--gp-900); --word-shadow: var(--gp-900);
  --trig: var(--pk-100); --trig-edge: var(--gp-900); --trig-shadow: 6px 7px 0 0 var(--gp-900);
  --on-trig: var(--gp-900); --on-trig-muted: var(--gp-600);
  --spark-1: var(--lm-300); --spark-2: var(--white); --spark-3: var(--sk-300); --spark-4: var(--mn-300); --fx-ring: var(--white); --fx-ink: var(--gp-900);
  --pill: var(--pk-100); --pill-edge: var(--gp-900); --on-pill: var(--gp-900); --on-pill-muted: var(--gp-600); --pill-icon: var(--gp-900); --on-pill-icon: var(--lm-300); --ring: var(--gp-900); --lockup: brightness(0);
}

A few things are behaviour, and app.js reads the look for them: the surface's corner radii, how the selection's two edges are sprung (a stiff stamp, a lit pane, a jelly), and which way the sparks fall.

app.js — the look, where behaviour differs
const CLOSED = { w: 340, h: 64, top: 366, r: { catalog: 14, night: 32, bubble: 32 }[LOOK] };
const OPEN = { w: 392, top: 226, r: { catalog: 7, night: 22, bubble: 28 }[LOOK] };
app.js — the selection per look
const SEL = {                                                    // the selection's two edges: the leading edge is stiff, the trailing one lags
  catalog: { lead: [640, 40], trail: [540, 38] },                // a stamp: nearly rigid, it lifts instead
  night: { lead: [520, 32], trail: [250, 23] },                  // a light pane: a clear stretch
  bubble: { lead: [430, 22], trail: [180, 12.5] },               // jelly: a big stretch and a wobble
}[LOOK];
app.js — gravity per look
const GRAV = { catalog: 560, night: -70, bubble: 640 }[LOOK];
Look · catalogOpen ↗
Look · nightOpen ↗
Look · bubbleOpen ↗

Make it yours

  • Looks are tokens: pick one with ?look=catalog, night or bubble (or a, b, c). ?open=1 starts with the palette open and ?q=pr starts with a query typed.
  • The content is GROUPS and ITEMS in app.js: a label, a hint, the shortcut keys, an icon (or initials), a short name for the giant word and extra search words in kw. How many results show is PER_GROUP (an empty query) and MAX_RESULTS (a query).
  • Nothing is sent anywhere. launch() is where a real site would navigate: it receives the chosen item, and the page only names it and says it was simulated.
  • The sizes are written twice, once in app.js (ROW_H, HD_H, FIELD_H) and once as CSS variables, so change them together. A row is 34 px, a header 20 px, the search field 50 px.
  • The feel is in the springs: setTargets() for the surface, SEL for the selection's two edges, and the row springs in applyView().
  • Accessibility is already there: a combobox with aria-activedescendant, a labelled listbox of groups, a dialog with a focus trap that returns focus to where it came from, one polite announcement per change, visible focus rings, and prefers-reduced-motion removing the morph, the stagger and the sparks.
  • Two honest limits: a query shows at most eight results (no scrolling, by design), and Home and End move the selection instead of the text caret.
The one idea worth keeping

Make the pill and the panel the same object. One box, with its width, height, top and corner radius each on its own spring, so the pill really grows into the palette instead of a popover appearing next to it. The same goes for the selection: one element with two edges on two springs, not a highlight that is redrawn on every row.