WorkspaceCommand palette
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callCommand palette
Jump anywhere or hand a task to the agent from one box. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add command-paletteReact
npx designme add command-palette --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A search box with a list of commands under it, grouped by heading, where a person can jump to a page, run an action, or pass what they typed to the agent. As soon as something is typed, the list narrows and a last row, "Ask the agent", sends the text to the agent instead. It gives an agent product one keyboard-first place to start anything.
- When people need a single shortcut that reaches every page and action in your app.
- When a typed request that matches no command should still reach the agent.
- When you want to show keyboard shortcuts next to the commands they trigger.
- Inside a modal opened by a key such as Cmd K, closed again with Escape.
- Filters
itemsas you type, matching the letters of the query in order anywhere in the label, and marks the matching letters in the accent. Rows that come into the list rise in a short cascade. - Lists commands under their
groupheading, each with an icon on a tile in itstone(blue, purple, green, orange, red, yellow, pink or teal, grey when left out), an optional quiethintafter the label, and optionalkeysshown as keyboard chips. - A grey highlight glides to the row the arrows or the pointer are on, and the keyboard chips on it turn white.
- Adds an "Ask the agent" row at the end whenever the box has text, with the text in quotes and Enter shown while it is highlighted, which calls
onAskwith that text. - Runs the highlighted command on click or Enter and calls
onRunwith it. The command's tile spins for a beat, then the footer says what ran, or shows your ownstatustext. - Works controlled through
queryandonQueryChange, or holds its own text withdefaultQuery.
- The list has role="listbox" and each row has role="option" with aria-selected on the highlighted one.
- The input is a
comboboxthat points at the highlighted row witharia-activedescendant, so the focus stays in the box and the rows are not tab stops. - Arrow Up and Arrow Down move the highlight and wrap around, and Enter runs the highlighted row.
- Escape clears the box first, and calls
onCloseonly when the box is already empty. - The input carries aria-label="Command".
- With reduced motion the highlight and the rows don't glide or rise, and a command's tile doesn't spin before the footer updates.
Usage
Import it and drive it from your own state.
Import
import { CommandPalette } from '@/components/designme/command-palette/CommandPalette';Example
The demo above as a React component, 41 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { CommandPalette, type CommandPaletteItem } from './CommandPalette';
import { useStory } from '../lib/useScript';
const COMMANDS: CommandPaletteItem[] = [
{ group: 'Agent', icon: 'spark', label: 'Audit this page for contrast', keys: ['⌘', 'J'], hint: 'WCAG AA', tone: 'purple' },
{ group: 'Agent', icon: 'wand', label: 'Tidy layer names in the selection', hint: '12 layers', tone: 'purple' },
{ group: 'Agent', icon: 'code', label: 'Export tokens as CSS variables', hint: 'tokens.css', tone: 'purple' },
{ group: 'Go to', icon: 'layers', label: 'Pricing v4', keys: ['G', 'P'], hint: 'Page', tone: 'blue' },
{ group: 'Go to', icon: 'figma', label: 'Checkout flow', hint: 'Figma file', tone: 'blue' },
{ group: 'Go to', icon: 'book', label: 'Memory', hint: 'Team notes', tone: 'blue' },
{ group: 'Actions', icon: 'users', label: 'Invite a teammate', hint: 'Acme Labs', tone: 'teal' },
{ group: 'Actions', icon: 'gear', label: 'Agent settings', keys: ['⌘', ','], tone: 'teal' },
{ group: 'Actions', icon: 'palette', label: 'Switch theme', hint: 'Light', tone: 'teal' }
];
/* ---------- Command palette: someone types "tok" and runs the token export, then finds the invite and runs it ---------- */
export default function CommandPaletteDemo() {
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
// clicking, focusing or typing in it takes over, and what it had typed goes
useStory(ref, async ({ wait, taken }) => {
let typing = false;
taken(() => { if (typing) setQuery(''); });
const type = async (text: string) => { typing = true; let typed = ''; for (const ch of text) { typed += ch; setQuery(typed); await wait(180); } typing = false; };
// Enter in the box runs the highlighted row, as a person pressing it would
const enter = () => ref.current?.querySelector('input')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await wait(1500);
await type('tok'); await wait(1000); enter(); await wait(2000);
setQuery(''); await wait(500);
await type('invi'); await wait(1000); enter();
});
return (
<div ref={ref} style={{ width: '100%' }}>
<CommandPalette items={COMMANDS} query={query} onQueryChange={setQuery} />
</div>
);
}
Props
Everything CommandPalette takes.
| Prop | Type | What it does |
|---|---|---|
itemsrequired | CommandPaletteItem[] | |
query | string | What is typed in the box. Leave it out to let the box hold its own |
defaultQuery | string | |
onQueryChange | (query: string) => void | |
onRun | (item: CommandPaletteItem) => void | Called when a command runs, by click or Enter |
onAsk | (query: string) => void | Called with the typed text when the last row, "Ask the agent", runs |
onClose | () => void | Called by Escape when the box is already empty |
placeholder | string | |
status | string | The quiet text at the end of the footer. Defaults to what ran last |
autoFocus | boolean | Puts the cursor in the box when it appears |
className | string |
Code
The full source, as the CLI installs it.
CommandPalette.tsxReact · 156 lines
'use client';
import { Fragment, useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import '../designme.css';
import './command-palette.css';
export type CommandTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
const TONE: Record<CommandTone, string> = { blue: 'pb', purple: 'pl', green: 'pm', orange: 'pp', red: 'pr', yellow: 'py', pink: 'pk', teal: 'pt' };
/** One command in the palette */
export interface CommandPaletteItem {
/** The group it is listed under, like "Go to" */
group: string;
icon: IconName;
/** What it does, like "Invite a teammate". Typing matches it letter by letter */
label: string;
/** Its shortcut, one key per entry, like ['⌘', 'J'] */
keys?: string[];
/** A quiet word or two after the label, like "tokens.css". It isn't matched, and gives way first on a narrow palette */
hint?: string;
/** The hue of its icon tile, usually one per group. Leave it out for grey */
tone?: CommandTone;
}
export interface CommandPaletteProps {
items: CommandPaletteItem[];
/** What is typed in the box. Leave it out to let the box hold its own */
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
/** Called when a command runs, by click or Enter */
onRun?: (item: CommandPaletteItem) => void;
/** Called with the typed text when the last row, "Ask the agent", runs */
onAsk?: (query: string) => void;
/** Called by Escape when the box is already empty */
onClose?: () => void;
placeholder?: string;
/** The quiet text at the end of the footer. Defaults to what ran last */
status?: string;
/** Puts the cursor in the box when it appears */
autoFocus?: boolean;
className?: string;
}
// letters of the query, in order, anywhere in the label
const matches = (label: string, q: string) => {
const l = label.toLowerCase(); let at = 0;
for (const ch of q.toLowerCase()) { at = l.indexOf(ch, at); if (at < 0) return false; at++; }
return true;
};
// every letter the query contains is marked, as on designme.dev
const marked = (label: string, q: string) => {
if (!q) return label;
const set = new Set(q.toLowerCase()), out: { t: string; m: boolean }[] = [];
for (const ch of label) { const m = set.has(ch.toLowerCase()), last = out[out.length - 1]; if (last && !last.m && !m) last.t += ch; else out.push({ t: ch, m }); }
return out.map((p, i) => p.m ? <mark key={i}>{p.t}</mark> : <Fragment key={i}>{p.t}</Fragment>);
};
/** Jump anywhere or hand a task to the agent from one box. Type to filter, arrows to move, Enter to run, Escape to clear.
Each command has a tile in its hue and a quiet hint; a command that runs spins its tile for a beat. */
export function CommandPalette({ items, query, defaultQuery = '', onQueryChange, onRun, onAsk, onClose, placeholder = 'Search or ask the agent', status, autoFocus, className }: CommandPaletteProps) {
const [q, setQ] = useControllable(query, defaultQuery, onQueryChange);
const v = q.trim();
const shown = items.filter(it => matches(it.label, v));
const rows = v ? [...shown, null] : shown; // null is the "Ask the agent" row
// the highlight goes back to the first row whenever the list changes
const [hi, setHi] = useState({ at: 0, for: v });
const at = hi.for === v ? Math.min(hi.at, Math.max(0, rows.length - 1)) : 0;
const [ran, setRan] = useState('');
// the row that just ran spins its tile for a beat before the footer says what ran
const [busy, setBusy] = useState<string>();
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
// rows that arrive after the first render play a short rise, in a cascade
const [ready, setReady] = useState(false);
useEffect(() => { setReady(true); }, []);
const list = useRef<HTMLDivElement>(null);
const btns = useRef<(HTMLButtonElement | null)[]>([]);
const id = useId(), reduce = useReducedMotion();
const [top, setTop] = useState<{ for: string; px: number }>();
useIsoLayoutEffect(() => {
const b = btns.current[at], l = list.current; if (!b || !l) return;
setTop({ for: v, px: b.offsetTop });
if (b.offsetTop < l.scrollTop || b.offsetTop + 36 > l.scrollTop + l.clientHeight) l.scrollTo({ top: b.offsetTop - 40, behavior: reduce ? 'auto' : 'smooth' });
}, [at, v, rows.length, reduce]);
// while there is more list below, its last row fades out at the edge instead of being cut off by the footer
const [more, setMore] = useState(false);
const edge = () => { const l = list.current; if (l) setMore(l.scrollTop + l.clientHeight < l.scrollHeight - 2); };
useIsoLayoutEffect(edge, [v, rows.length]);
const move = (d: number) => { if (rows.length) setHi({ at: (at + d + rows.length) % rows.length, for: v }); };
const run = (i: number) => {
const row = rows[i], b = btns.current[i];
if (row === undefined) return;
if (row) onRun?.(row); else onAsk?.(v);
const said = row ? 'Ran: ' + row.label : 'Sent to the agent', k = row ? row.group + row.label : '\u0000ask';
setBusy(k); clearTimeout(timer.current);
timer.current = setTimeout(() => { setBusy(b => b === k ? undefined : b); setRan(said); }, reduce ? 0 : 650);
b?.animate?.([{ transform: 'scale(1)' }, { transform: 'scale(.98)' }, { transform: 'scale(1)' }], { duration: 260, easing: 'ease-out' });
};
const key = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') { move(1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(-1); e.preventDefault(); }
else if (e.key === 'Enter') run(at);
else if (e.key === 'Escape') { if (q) setQ(''); else onClose?.(); }
};
let group = '';
return (
<div data-dm="" className={cx('dm-cpl dm-panel', className)}>
<div className="dm-cpl-in">
<Icon name="search" />
{/* the box keeps the focus and points at the highlighted row, so the rows are not tab stops */}
<input className="dm-cpl-q" placeholder={placeholder} aria-label="Command" spellCheck={false} autoFocus={autoFocus} value={q}
role="combobox" aria-expanded aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={rows.length ? `${id}-${at}` : undefined}
onChange={e => setQ(e.target.value)} onKeyDown={key} />
<span className="dm-kbd">esc</span>
</div>
<div className={cx('dm-cpl-list', more && 'dm-more')} role="listbox" id={`${id}-list`} aria-label="Commands" tabIndex={-1} ref={list} onScroll={edge}>
{/* a new list starts the highlight afresh, so it jumps into place instead of sliding */}
<span key={v} className="dm-cpl-hl" style={{ top: top?.for === v ? top.px : undefined }} />
{rows.map((row, i) => {
const head = row ? row.group : 'Ask', k = row ? row.group + row.label : '\u0000ask';
const g = head !== group ? <div className="dm-cpl-g">{(group = head)}</div> : null;
const tone = row?.tone ? TONE[row.tone] : undefined;
return (
<Fragment key={k}>
{g}
<button type="button" ref={el => { btns.current[i] = el; }} className={cx('dm-cpl-i', !row && 'dm-ask', i === at && 'dm-hl', ready && 'dm-in')}
style={ready ? { animationDelay: `${Math.min(i, 6) * 28}ms` } : undefined}
role="option" id={`${id}-${i}`} tabIndex={-1} aria-selected={i === at} onMouseEnter={() => setHi({ at: i, for: v })} onClick={() => run(i)}>
<span className="dm-ic" style={tone ? { '--dm-tc': `var(--dm-${tone})`, '--dm-tb': `var(--dm-${tone}-bg)` } as CSSProperties : undefined}>
{busy === k ? <span className="dm-spin" aria-hidden="true" /> : <Icon name={row ? row.icon : 'spark'} size="xs" />}
</span>
<span className="dm-t">{row ? marked(row.label, v) : <>Ask the agent <span className="dm-q">“{v}”</span></>}</span>
{row?.hint && <span className="dm-h">{row.hint}</span>}
{row?.keys && <span className="dm-k">{row.keys.map((k, j) => <span key={j} className="dm-kbd">{k}</span>)}</span>}
{!row && <span className="dm-kbd dm-ent" aria-hidden="true">↵</span>}
</button>
</Fragment>
);
})}
</div>
<div className="dm-cpl-f dm-muted">
<span><span className="dm-kbd">↑</span><span className="dm-kbd">↓</span> move</span>
<span><span className="dm-kbd">↵</span> run</span>
<span className="dm-cpl-st">{(status ?? ran) && <span key={status ?? ran} className="dm-enter"><span className="dm-dot" aria-hidden="true" />{status ?? ran}</span>}</span>
</div>
</div>
);
}
command-palette.cssReact · 42 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Command palette ---------- */
/* one quiet box: each command has a tile in its group's hue and a muted hint after its name; a grey highlight glides to the
row the arrows are on, and a command that runs turns its tile into a spinner for a beat */
.dm-cpl{overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--dm-shadow-pop)}
.dm-cpl-in{display:flex;align-items:center;gap:10px;padding:var(--dm-s-3) 14px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-muted)}
.dm-cpl-q{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:14px;color:var(--dm-fg);caret-color:var(--dm-accent-fg,var(--dm-fg))}
.dm-cpl-q::placeholder{color:var(--dm-muted)}
/* the line under the search box steps up while it has the focus; a row that takes the focus rings on its own */
.dm-cpl-list{padding:6px;height:236px;overflow-y:auto;position:relative}
/* while there is more list below, its last row fades out at the edge instead of being cut off by the footer */
.dm-cpl-list.dm-more{-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent)}
.dm-cpl-g{font-size:11.5px;color:var(--dm-muted);padding:var(--dm-s-2) var(--dm-s-2) var(--dm-s-1)}
.dm-cpl-i{display:flex;align-items:center;gap:10px;width:100%;height:36px;padding:0 var(--dm-s-2);border-radius:10px;font-size:13.5px;text-align:left;color:var(--dm-fg);position:relative;z-index:1}
/* rows arrive in a short cascade when the list narrows */
.dm-cpl-i.dm-in{animation:dm-cplin .32s var(--dm-ease) backwards}
@keyframes dm-cplin{from{opacity:0;transform:translateY(4px)}}
.dm-cpl-i .dm-ic{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:none;color:var(--dm-tc,var(--dm-fg2));background:var(--dm-tb,var(--dm-chip));transition:background .2s,color .2s,transform .2s var(--dm-ease)}
.dm-cpl-i.dm-hl .dm-ic{transform:scale(1.06)}
.dm-cpl-i .dm-ic .dm-spin{width:11px;height:11px}
/* a label too long for a narrow palette ends in an ellipsis on its one line, so it never spills out of its row; the hint
gives way first */
.dm-cpl-i .dm-t{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-cpl-i .dm-h{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dm-muted)}
.dm-cpl-i.dm-hl .dm-h{color:var(--dm-fg2)}
.dm-cpl-i .dm-k{margin-left:auto;display:flex;gap:3px;flex:none}
.dm-cpl-i mark{background:transparent;color:var(--dm-accent-fg,var(--dm-accent));font-weight:500}
/* the row that hands the text to the agent: an accent tile, the words in quotes, and Enter shown while it is highlighted */
.dm-cpl-i.dm-ask .dm-ic{background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-cpl-i.dm-ask .dm-q{color:var(--dm-fg2)}
.dm-cpl-i .dm-ent{margin-left:auto;opacity:0;transition:opacity .2s}
.dm-cpl-i.dm-hl .dm-ent{opacity:1}
.dm-cpl-hl{position:absolute;left:6px;right:6px;height:36px;border-radius:10px;background:var(--dm-row);transition:top .25s var(--dm-ease);z-index:0}
.dm-cpl-f{display:flex;gap:14px;align-items:center;padding:var(--dm-s-2) 14px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:11.5px}
.dm-cpl-f>span{flex:none;white-space:nowrap}
.dm-cpl-f .dm-kbd{margin-right:3px}
.dm-cpl-f>.dm-cpl-st{flex:0 1 auto;min-width:0;margin-left:auto;color:var(--dm-fg2);overflow:hidden;text-overflow:ellipsis}
.dm-cpl-st .dm-dot{color:var(--dm-ok);margin-right:6px;vertical-align:1px}
.dm-cpl-empty{padding:30px 10px;text-align:center;font-size:13px;color:var(--dm-muted)}
:root:not([data-style="soft"]) .dm-cpl-i.dm-hl .dm-ic:not(:where([data-style="soft"],[data-style="soft"] *)){transform:none}
:root:not([data-style="soft"]) .dm-cpl-i.dm-in:not(:where([data-style="soft"],[data-style="soft"] *)){animation:dm-bpin .3s var(--dm-ease) backwards}
command-palette.htmlHTML · 10 lines
<div class="card" data-c="command-palette">
<div class="stage-in">
<div class="cpl panel">
<div class="cpl-in"><svg class="i s"><use href="#i-search"/></svg><input class="cpl-q" placeholder="Search or ask the agent" aria-label="Command" spellcheck="false"><span class="kbd">esc</span></div>
<div class="cpl-list" role="listbox"></div>
<div class="cpl-f muted"><span><span class="kbd">↑</span><span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> run</span><span class="cpl-st"></span></div>
</div>
</div>
</div>
command-palette.cssHTML · 41 lines
/* ---------- Command palette ---------- */
/* one quiet box: each command has a tile in its group's hue and a muted hint after its name; a grey highlight glides to the
row the arrows are on, and a command that runs turns its tile into a spinner for a beat */
.cpl{overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-pop)}
.cpl-in{display:flex;align-items:center;gap:10px;padding:var(--s-3) 14px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);color:var(--muted)}
.cpl-q{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:14px;color:var(--fg);caret-color:var(--accent-fg,var(--fg))}
.cpl-q::placeholder{color:var(--muted)}
/* the line under the search box steps up while it has the focus; a row that takes the focus rings on its own */
.cpl-list{padding:6px;height:236px;overflow-y:auto;position:relative}
/* while there is more list below, its last row fades out at the edge instead of being cut off by the footer */
.cpl-list.more{-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent)}
.cpl-g{font-size:11.5px;color:var(--muted);padding:var(--s-2) var(--s-2) var(--s-1)}
.cpl-i{display:flex;align-items:center;gap:10px;width:100%;height:36px;padding:0 var(--s-2);border-radius:10px;font-size:13.5px;text-align:left;color:var(--fg);position:relative;z-index:1}
/* rows arrive in a short cascade when the list narrows */
.cpl-i.in{animation:cplin .32s var(--ease) backwards}
@keyframes cplin{from{opacity:0;transform:translateY(4px)}}
.cpl-i .ic{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:none;color:var(--tc,var(--fg2));background:var(--tb,var(--chip));transition:background .2s,color .2s,transform .2s var(--ease)}
.cpl-i.hl .ic{transform:scale(1.06)}
.cpl-i .ic .spin{width:11px;height:11px}
/* a label too long for a narrow palette ends in an ellipsis on its one line, so it never spills out of its row; the hint
gives way first */
.cpl-i .t{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cpl-i .h{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--muted)}
.cpl-i.hl .h{color:var(--fg2)}
.cpl-i .k{margin-left:auto;display:flex;gap:3px;flex:none}
.cpl-i mark{background:transparent;color:var(--accent-fg,var(--accent));font-weight:500}
/* the row that hands the text to the agent: an accent tile, the words in quotes, and Enter shown while it is highlighted */
.cpl-i.ask .ic{background:var(--accent);color:var(--on-accent)}
.cpl-i.ask .q{color:var(--fg2)}
.cpl-i .ent{margin-left:auto;opacity:0;transition:opacity .2s}
.cpl-i.hl .ent{opacity:1}
.cpl-hl{position:absolute;left:6px;right:6px;height:36px;border-radius:10px;background:var(--row);transition:top .25s var(--ease);z-index:0}
.cpl-f{display:flex;gap:14px;align-items:center;padding:var(--s-2) 14px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:11.5px}
.cpl-f>span{flex:none;white-space:nowrap}
.cpl-f .kbd{margin-right:3px}
.cpl-f>.cpl-st{flex:0 1 auto;min-width:0;margin-left:auto;color:var(--fg2);overflow:hidden;text-overflow:ellipsis}
.cpl-st .dot{color:var(--ok);margin-right:6px;vertical-align:1px}
.cpl-empty{padding:30px 10px;text-align:center;font-size:13px;color:var(--muted)}
:root:not([data-style="soft"]) .cpl-i.hl .ic:not(:where([data-style="soft"],[data-style="soft"] *)){transform:none}
:root:not([data-style="soft"]) .cpl-i.in:not(:where([data-style="soft"],[data-style="soft"] *)){animation:bpin .3s var(--ease) backwards}
command-palette.jsHTML · 59 lines
DM.def('command-palette', (r, c) => {
r.innerHTML = r.innerHTML.replace('{search}', I('search'));
// [group, icon, label, keys, hint]; each group's tiles take its hue
const C = [
['Agent','spark','Audit this page for contrast',['⌘','J'],'WCAG AA'],
['Agent','wand','Tidy layer names in the selection',null,'12 layers'],
['Agent','code','Export tokens as CSS variables',null,'tokens.css'],
['Go to','layers','Pricing v4',['G','P'],'Page'],
['Go to','figma','Checkout flow',null,'Figma file'],
['Go to','book','Memory',null,'Team notes'],
['Actions','users','Invite a teammate',null,'Acme Labs'],
['Actions','gear','Agent settings',['⌘',',']],
['Actions','palette','Switch theme',null,'Light']
];
const TONE = { Agent:'pl', 'Go to':'pb', Actions:'pt' };
const q = $('.cpl-q', r), list = $('.cpl-list', r), st = $('.cpl-st', r);
list.id = 'cpl-l-' + Math.random().toString(36).slice(2, 7); list.setAttribute('aria-label', 'Commands'); list.tabIndex = -1;
q.setAttribute('role', 'combobox'); q.setAttribute('aria-expanded', 'true'); q.setAttribute('aria-controls', list.id); q.setAttribute('aria-autocomplete', 'list');
let items = [], hi = 0, last = C.map(x => x[2]).join('|');
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function paint(){
const v = q.value.trim(), re = v ? new RegExp('(' + v.split('').map(esc).join(').*?(') + ')', 'i') : null;
const m = C.filter(x => !re || re.test(x[2]));
// the rows play a short cascade only when the list changes, not on every letter that keeps it as it is
const key = m.map(x => x[2]).join('|') + (v ? '|ask' : ''), fresh = key !== last; last = key;
let h = '<span class="cpl-hl"></span>', g = '', n = 0;
const cls = () => fresh ? ` in" style="animation-delay:${Math.min(n++, 6) * 28}ms` : '';
m.forEach(x => { if (x[0] !== g){ g = x[0]; h += `<div class="cpl-g">${g}</div>`; } const label = v ? x[2].replace(new RegExp(v.split('').map(esc).join('|'), 'gi'), s => `<mark>${s}</mark>`) : x[2], t = TONE[x[0]];
h += `<button class="cpl-i${cls()}" role="option" data-t="${x[2]}"><span class="ic" style="--tc:var(--${t});--tb:var(--${t}-bg)">${I(x[1],'xs')}</span><span class="t">${label}</span>${x[4] ? `<span class="h">${x[4]}</span>` : ''}${x[3] ? `<span class="k">${x[3].map(k => `<span class="kbd">${k}</span>`).join('')}</span>` : ''}</button>`; });
if (v) h += `<div class="cpl-g">Ask</div><button class="cpl-i ask${cls()}" data-t="${v.replace(/"/g,'"')}" data-ask="1"><span class="ic">${I('spark','xs')}</span><span class="t">Ask the agent <span class="q">“${v.replace(/</g,'<')}”</span></span><span class="kbd ent">↵</span></button>`;
list.innerHTML = h; items = $$('.cpl-i', list); hi = 0;
// the search box keeps the focus and points at the highlighted row (aria-activedescendant), so the rows are not tab stops
items.forEach((b, i) => { b.id = list.id + '-' + i; b.tabIndex = -1; b.setAttribute('role', 'option'); b.onmouseenter = () => move(i - hi); b.onclick = () => run(b); });
if (!items.length) q.removeAttribute('aria-activedescendant'); move(0); edge();
}
function move(d){ if (!items.length) return; hi = (hi + d + items.length) % items.length; items.forEach((b, i) => { b.classList.toggle('hl', i === hi); b.setAttribute('aria-selected', i === hi); }); const b = items[hi], hl = $('.cpl-hl', list); q.setAttribute('aria-activedescendant', b.id); hl.style.top = b.offsetTop + 'px'; if (b.offsetTop < list.scrollTop || b.offsetTop + 36 > list.scrollTop + list.clientHeight) list.scrollTo({ top:b.offsetTop - 40, behavior:REDUCE ? 'auto' : 'smooth' }); }
const edge = () => list.classList.toggle('more', list.scrollTop + list.clientHeight < list.scrollHeight - 2);
c.on(list, 'scroll', edge, { passive:true });
// a command that runs turns its tile into a spinner for a beat, then the footer says what ran
function run(b){
b.animate([{ transform:'scale(1)' },{ transform:'scale(.98)' },{ transform:'scale(1)' }], { duration:260, easing:'ease-out' });
const ic = $('.ic', b), was = ic.innerHTML; ic.innerHTML = '<span class="spin"></span>';
setTimeout(() => { if (ic.isConnected){ ic.innerHTML = was; enter(ic.firstElementChild); } swap(st, `<span class="dot"></span>${b.dataset.ask ? 'Sent to the agent' : 'Ran: ' + b.dataset.t}`, true); }, REDUCE ? 0 : 650);
}
q.oninput = paint;
q.onkeydown = e => { if (e.key === 'ArrowDown'){ move(1); e.preventDefault(); } else if (e.key === 'ArrowUp'){ move(-1); e.preventDefault(); } else if (e.key === 'Enter' && items[hi]) run(items[hi]); else if (e.key === 'Escape'){ q.value = ''; paint(); } };
paint();
// the preview's story: someone types "tok" and runs the token export, then looks for "invi" and runs the invite; clicking,
// focusing or typing in it takes over, and what it had typed goes
c.story(async s => {
await s.beat(3);
await s.type(q, 'tok', { n:.36, each:paint });
await s.beat(2); run(items[hi]); await s.beat(4);
await s.type(q, '', { n:.36, each:paint, clear:true }); await s.beat(1);
await s.type(q, 'invi', { n:.36, each:paint });
await s.beat(2); run(items[hi]); await s.beat(4);
});
});
Questions
What people ask about it.
- How do I send a free-text request to the agent?
- Pass
onAsk. When the box has text, the last row reads "Ask the agent" with the typed text in quotes, and running it callsonAskwith the trimmed query. - How does the filter match commands?
- Each letter of the query must appear in the label in the same order, though not side by side. So "tok" matches "Export tokens as CSS variables".
- How do I close the palette with Escape?
- Pass
onClose. The first Escape clears any text, and the next Escape, on an empty box, callsonCloseso you can hide the palette. - Can I focus the input when the palette opens?
- Yes. Set
autoFocusand the cursor goes into the box when it appears.
More in Workspace
Navigation, search, settings, models and automations around the agent.