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.
<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.
<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 & 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.
: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].
[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.
.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.
.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); }
/* 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; }
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.
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.
// 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.
// 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.
// 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.
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: [] };
});
// 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.
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`;
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:
/* 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); }
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.
<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.
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
}
// 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.
// ---- 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.
// ---- 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.
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');
<!-- 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.
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);
}
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.
// 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.
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
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.
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.
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.
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();
}
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.
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;
}
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.
<!-- 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>
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;
}
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.
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(); });
}
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; });
}
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;
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.
function draw(dt = 0) {
html.style.setProperty('--bump', (1 + 0.04 * bump.value).toFixed(4));
renderSurface(); renderRows(); renderSelection(); renderEmpty();
renderWord(); renderFx(dt);
}
.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.
if (empty) {
emp.target = 1; selOn.target = 0;
shake.velocity += 340;
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)})`);
}
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.
<!-- 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.
// 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 });
/* ---- 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.
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)`;
}
}
[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.
<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>
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.
/* 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.
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.
[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.
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] };
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];
const GRAV = { catalog: 560, night: -70, bubble: 640 }[LOOK];
Make it yours
- Looks are tokens: pick one with
?look=catalog,nightorbubble(ora,b,c).?open=1starts with the palette open and?q=prstarts with a query typed. - The content is
GROUPSandITEMSinapp.js: a label, a hint, the shortcut keys, an icon (or initials), a short name for the giant word and extra search words inkw. How many results show isPER_GROUP(an empty query) andMAX_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,SELfor the selection's two edges, and the row springs inapplyView(). - 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, andprefers-reduced-motionremoving 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.
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.