Exclusivebeta
Combo — <mv-combo>
Keyboard sequence-shortcut engine for power-user apps, with a live input buffer. Moves are sequences of steps (“g i”, “g p a”), each a key or a chord (“Mod+k”, “Alt+ArrowDown”; Mod is ⌘ on Apple, Ctrl elsewhere), declared with <mv-combo-move keys href|for label group>, with data-combo on any element (the move clicks it, or focuses a field) or with register({ keys, label, run }). Every step must follow the previous one within a timing window (1.5 s by default) that a thin line drains in a small aria-hidden buffer showing the keys typed so far; after a short pause mid-sequence, a continuation panel lists what can follow, filtered live, and stops the clock so slow typists never lose the sequence. Built-in slop: Caps Lock is ignored, non-Latin layouts and macOS Option match by physical key, a stray key restarts the sequence from itself, Backspace removes the last step. A move that is also the prefix of another (“g p” and “g p a”) waits out the window, or runs at once on Enter. mv-combo is cancelable (SPA routing); ? opens a cheat-sheet <dialog> generated from the live moves, where each shortcut can be remapped by recording new keys (conflicts detected, saved with persist) and character-key shortcuts switched off. Keys are displayed per platform (⌘⇧P vs Ctrl+Shift+P), [data-combo-hint] elements are painted with the current keys, and typing in fields, menus or grids is never hijacked.
| Category | Utilities |
|---|---|
| Type | Web Component (<mv-combo>) |
| Status | beta |
| Also installs | button, input, kbd, switch, checkbox |
| Keywords | exclusive, culture, keyboard, shortcuts, hotkeys, keybindings, sequence, chord, which-key, cheat-sheet, remap, power-user, accessibility, wcag-2.1.4, navigation, input-display |
When to use
- A dense web app (tracker, inbox, admin, editor) needs multi-key shortcuts like g then i to jump between sections
- Power users want shortcuts they can discover by pausing mid-sequence instead of memorizing a cheat sheet first
- Shortcuts must satisfy WCAG 2.1.4: users can turn character-key shortcuts off or remap them, saved per user
- One declarative list of shortcuts should drive the key hints in menus, the cheat sheet and the handlers at once
Avoid when
- Users search a large set of actions by name rather than remembering keys → use Command instead
- The only need is to display a shortcut next to a label, with no behavior attached → use Kbd instead
- The page is mostly text entry (a document editor, a chat): single keys belong to typing and chords to the editor
Install
node scripts/add.mjs combo --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["combo"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/combo/combo.js, components/combo/combo.css, components/button/button.css, components/input/input.css, components/kbd/kbd.css, components/switch/switch.css, components/checkbox/checkbox.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="cb-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#cb-demo { display:grid; gap:1rem }
#cb-demo .cb-app { display:grid; grid-template-columns:13.5rem minmax(0,1fr); min-height:30rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden; transition:grid-template-columns var(--mv-duration-normal) var(--mv-ease-out) }
#cb-demo .cb-app[data-collapsed] { grid-template-columns:3.75rem minmax(0,1fr) }
#cb-demo .cb-side { display:flex; flex-direction:column; gap:1rem; min-width:0; padding:1rem .75rem; border-inline-end:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#cb-demo .cb-brand { display:flex; align-items:center; gap:.5rem; padding:0 .375rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em; white-space:nowrap }
#cb-demo .cb-logo { display:grid; place-items:center; flex:none; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#cb-demo .cb-logo svg { width:1rem; height:1rem }
#cb-demo .cb-nav { display:grid; gap:.125rem }
#cb-demo .cb-nav a { display:flex; align-items:center; gap:.625rem; height:2rem; padding:0 .5rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none; white-space:nowrap; transition:background-color var(--mv-duration-fast), color var(--mv-duration-fast) }
#cb-demo .cb-nav a:hover { background:var(--mv-bg-muted); color:var(--mv-fg) }
#cb-demo .cb-nav a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#cb-demo .cb-nav a[aria-current="page"] { background:var(--mv-surface); color:var(--mv-fg); box-shadow:var(--mv-shadow-xs) }
#cb-demo .cb-nav svg { flex:none; width:1rem; height:1rem }
#cb-demo .cb-nav .cb-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis }
#cb-demo .cb-count { color:var(--mv-fg-subtle); font-size:.75rem; font-variant-numeric:tabular-nums }
#cb-demo .cb-nav [data-combo-hint] { opacity:.75 }
#cb-demo .cb-nav a:hover [data-combo-hint], #cb-demo .cb-nav a[aria-current] [data-combo-hint] { opacity:1 }
#cb-demo .cb-app[data-collapsed] :is(.cb-name, .cb-count, .cb-brand-name, [data-combo-hint], .cb-tip) { display:none }
#cb-demo .cb-tip { margin:auto .375rem 0; color:var(--mv-fg-subtle); font-size:.75rem; line-height:1.5 }
#cb-demo .cb-main { position:relative; display:flex; flex-direction:column; min-width:0 }
#cb-demo .cb-top { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#cb-demo .cb-heading { flex:1 1 12rem; min-width:0 }
#cb-demo .cb-heading h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#cb-demo .cb-heading p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#cb-demo .cb-search { position:relative; flex:0 1 14rem }
#cb-demo .cb-search .mv-input { width:100%; box-sizing:border-box; padding-inline-end:3rem }
#cb-demo .cb-search [data-combo-hint] { position:absolute; inset-inline-end:.5rem; top:50%; translate:0 -50%; pointer-events:none }
#cb-demo .cb-list { margin:0; padding:.5rem; list-style:none; display:grid; gap:.125rem }
#cb-demo .cb-issue { display:grid; grid-template-columns:auto 4.25rem minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.625rem .75rem; border-radius:var(--mv-radius-md); color:inherit; text-decoration:none; font-size:.8125rem; transition:padding var(--mv-duration-normal) var(--mv-ease-out), background-color var(--mv-duration-fast) }
#cb-demo .cb-app[data-compact] .cb-issue { padding-block:.3125rem }
#cb-demo .cb-issue:hover { background:var(--mv-bg-subtle) }
#cb-demo .cb-issue:focus-visible { outline:none; box-shadow:var(--mv-focus-ring); background:var(--mv-bg-subtle) }
#cb-demo .cb-dot { width:.625rem; height:.625rem; border-radius:50%; border:1.5px solid var(--mv-fg-subtle) }
#cb-demo .cb-dot[data-s="progress"] { border-color:var(--mv-warning); background:linear-gradient(90deg, var(--mv-warning) 50%, transparent 0) }
#cb-demo .cb-dot[data-s="review"] { border-color:var(--mv-accent); background:var(--mv-accent-subtle) }
#cb-demo .cb-dot[data-s="done"] { border-color:var(--mv-success); background:var(--mv-success) }
#cb-demo .cb-id { color:var(--mv-fg-subtle); font-variant-numeric:tabular-nums }
#cb-demo .cb-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#cb-demo .cb-date { color:var(--mv-fg-subtle); font-size:.75rem; white-space:nowrap }
#cb-demo .cb-who { display:grid; place-items:center; width:1.5rem; height:1.5rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.625rem; font-weight:600 }
#cb-demo .cb-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
#cb-demo .cb-group { display:flex; align-items:center; gap:.625rem 1rem; flex-wrap:wrap }
#cb-demo .cb-field { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#cb-demo .mv-choice { font-size:.8125rem }
#cb-demo .cb-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:center }
@media (max-width:44rem) {
#cb-demo .cb-app { grid-template-columns:minmax(0,1fr) }
#cb-demo .cb-side { display:none }
#cb-demo .cb-issue { grid-template-columns:auto minmax(0,1fr) auto }
#cb-demo .cb-id, #cb-demo .cb-date { display:none }
}
</style>
<section class="cb-app" id="cb-app" aria-label="Issue tracker">
<aside class="cb-side">
<span class="cb-brand">
<span class="cb-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V5l14 14V5"/></svg></span>
<span class="cb-brand-name">Northwind</span>
</span>
<nav class="cb-nav" aria-label="Workspace" data-combo-group="Navigation">
<a href="#inbox" data-view="inbox" data-combo="g i" data-combo-label="Go to inbox" aria-current="page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5h13L22 12v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6z"/></svg>
<span class="cb-name">Inbox</span><span class="cb-count">3</span><kbd data-combo-hint></kbd>
</a>
<a href="#my-issues" data-view="my-issues" data-combo="g m" data-combo-label="Go to my issues">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/></svg>
<span class="cb-name">My issues</span><kbd data-combo-hint></kbd>
</a>
<a href="#projects" data-view="projects" data-combo="g p" data-combo-label="Go to projects">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span class="cb-name">Projects</span><kbd data-combo-hint></kbd>
</a>
<a href="#cycles" data-view="cycles" data-combo="g c" data-combo-label="Go to cycles">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
<span class="cb-name">Cycles</span><kbd data-combo-hint></kbd>
</a>
<a href="#settings" data-view="settings" data-combo="g s" data-combo-label="Go to settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg>
<span class="cb-name">Settings</span><kbd data-combo-hint></kbd>
</a>
</nav>
<p class="cb-tip">Press <kbd class="mv-kbd" data-variant="flat">?</kbd> for every shortcut. Pause after the first key to see what comes next.</p>
</aside>
<div class="cb-main">
<header class="cb-top">
<div class="cb-heading">
<h3 id="cb-title">Inbox</h3>
<p id="cb-sub">3 unread · updated 2 minutes ago</p>
</div>
<div class="cb-search">
<input id="cb-search" class="mv-input" data-size="sm" type="search" placeholder="Search issues" aria-label="Search issues">
<kbd data-combo-hint="search-issues"></kbd>
</div>
<button type="button" class="mv-button" data-size="sm" id="cb-new" data-combo="n i" data-combo-label="New issue" data-combo-group="Issues">
New issue <kbd data-combo-hint></kbd>
</button>
</header>
<ul class="cb-list" aria-label="Issues">
<li><a class="cb-issue" href="#NW-214"><span class="cb-dot" data-s="progress" aria-label="In progress"></span><span class="cb-id">NW-214</span><span class="cb-t">Checkout fails when the coupon field is left empty</span><span class="cb-date">Sep 22</span><span class="cb-who" aria-label="Assigned to Amara Okafor">AO</span></a></li>
<li><a class="cb-issue" href="#NW-209"><span class="cb-dot" data-s="review" aria-label="In review"></span><span class="cb-id">NW-209</span><span class="cb-t">Localize invoice PDFs for Japanese and German customers</span><span class="cb-date">Sep 21</span><span class="cb-who" aria-label="Assigned to Kenji Watanabe">KW</span></a></li>
<li><a class="cb-issue" href="#NW-203"><span class="cb-dot" aria-label="To do"></span><span class="cb-id">NW-203</span><span class="cb-t">Add SSO login with Okta for the Enterprise plan</span><span class="cb-date">Sep 19</span><span class="cb-who" aria-label="Assigned to Lucía Fernández">LF</span></a></li>
<li><a class="cb-issue" href="#NW-198"><span class="cb-dot" data-s="done" aria-label="Done"></span><span class="cb-id">NW-198</span><span class="cb-t">Dashboard charts flicker on Safari 18 when resizing</span><span class="cb-date">Sep 17</span><span class="cb-who" aria-label="Assigned to Noah Schmidt">NS</span></a></li>
<li><a class="cb-issue" href="#NW-191"><span class="cb-dot" aria-label="To do"></span><span class="cb-id">NW-191</span><span class="cb-t">Export monthly usage to CSV from the billing page</span><span class="cb-date">Sep 15</span><span class="cb-who" aria-label="Assigned to Priya Raman">PR</span></a></li>
</ul>
<mv-combo id="cb-combo" contained persist="cb-demo">
<mv-combo-move keys="g p a" name="archived-projects" label="Go to archived projects" group="Navigation"></mv-combo-move>
<mv-combo-move keys="Mod+k" for="#cb-search" name="search-issues" label="Search issues" group="Issues" in-fields></mv-combo-move>
</mv-combo>
</div>
</section>
<div class="cb-controls">
<span class="cb-group">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cb-on" checked> Shortcuts</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cb-chars" checked> Character keys</label>
<span class="cb-field"><span id="cb-display-l">Display</span>
<mv-segmented id="cb-display" value="buffer" aria-labelledby="cb-display-l">
<button value="buffer">Buffer</button><button value="history">History</button><button value="none">Off</button>
</mv-segmented>
</span>
<span class="cb-field"><span id="cb-platform-l">Keys</span>
<mv-segmented id="cb-platform" value="auto" aria-labelledby="cb-platform-l">
<button value="auto">Auto</button><button value="mac">macOS</button><button value="windows">Windows</button>
</mv-segmented>
</span>
</span>
<span class="cb-group">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cb-replay">Replay G P A</button>
<button type="button" class="mv-button" data-size="sm" id="cb-sheet">All shortcuts <kbd class="mv-kbd">?</kbd></button>
</span>
</div>
<p class="cb-log" id="cb-log" aria-live="polite">Type G, then pause: the next keys appear.</p>
<script type="module">
const combo = document.getElementById("cb-combo");
const app = document.getElementById("cb-app");
const log = document.getElementById("cb-log");
const title = document.getElementById("cb-title");
const sub = document.getElementById("cb-sub");
const nav = [...app.querySelectorAll(".cb-nav a")];
const VIEWS = {
inbox: ["Inbox", "3 unread · updated 2 minutes ago", "inbox"],
"my-issues": ["My issues", "5 open · 2 due this week", "my-issues"],
projects: ["Projects", "8 active · Q4 roadmap", "projects"],
archived: ["Archived projects", "14 archived · last on Aug 29, 2026", "projects"],
cycles: ["Cycles", "Cycle 38 · Sep 15 – Sep 28", "cycles"],
settings: ["Settings", "Workspace, members and billing", "settings"],
};
const say = (text) => { log.textContent = text; };
const show = (view) => {
const [t, s, navKey] = VIEWS[view];
title.textContent = t;
sub.textContent = s;
for (const a of nav) {
if (a.dataset.view === navKey) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
};
for (const a of nav) a.addEventListener("click", (e) => { e.preventDefault(); show(a.dataset.view); });
for (const a of app.querySelectorAll(".cb-issue")) a.addEventListener("click", (e) => e.preventDefault());
document.getElementById("cb-new").addEventListener("click", () => say("New issue draft opened in Northwind."));
// Moves registered from JS: behavior lives in run().
const rows = () => [...app.querySelectorAll(".cb-issue")];
const step = (d) => {
const list = rows();
const i = list.indexOf(document.activeElement);
list[(i + d + list.length) % list.length].focus();
};
combo.register({ id: "next-issue", keys: "Alt+ArrowDown", label: "Next issue", group: "Issues", run: () => step(1) });
combo.register({ id: "previous-issue", keys: "Alt+ArrowUp", label: "Previous issue", group: "Issues", run: () => step(-1) });
combo.register({ id: "toggle-sidebar", keys: "t s", label: "Toggle sidebar", group: "View", run: () => app.toggleAttribute("data-collapsed") });
combo.register({ id: "toggle-compact", keys: "t d", label: "Toggle compact rows", group: "View", run: () => app.toggleAttribute("data-compact") });
combo.addEventListener("mv-combo", (e) => {
const { id, label, keys } = e.detail;
if (id === "archived-projects") show("archived");
say(`mv-combo · ${combo.format(keys)} → ${label}`);
});
combo.addEventListener("mv-combo-miss", (e) => say(`mv-combo-miss · ${combo.format(e.detail.keys)} (${e.detail.reason})`));
combo.addEventListener("mv-combo-remap", (e) => say(e.detail.reset ? "mv-combo-remap · default restored" : `mv-combo-remap · ${e.detail.label} → ${combo.format(e.detail.keys)}`));
const on = document.getElementById("cb-on");
const chars = document.getElementById("cb-chars");
chars.checked = combo.characterKeys;
on.addEventListener("change", () => { combo.disabled = !on.checked; });
chars.addEventListener("change", () => { combo.characterKeys = chars.checked; });
combo.addEventListener("mv-combo-change", (e) => { chars.checked = e.detail.characterKeys; });
document.getElementById("cb-display").addEventListener("mv-change", (e) => { combo.display = e.detail.value; });
document.getElementById("cb-platform").addEventListener("mv-change", (e) => { combo.platform = e.detail.value; });
document.getElementById("cb-sheet").addEventListener("click", () => combo.openSheet());
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
document.getElementById("cb-replay").addEventListener("click", async () => {
combo.cancel();
combo.press("g");
await wait(900); // pause: the continuation panel appears and the clock stops
combo.press("p");
await wait(500);
combo.press("a");
});
// Initial state: a sequence already started, waiting for its next key.
setTimeout(() => { if (combo.characterKeys && !combo.disabled) combo.press("g"); }, 150);
</script>
</div>Cultural reference
Street Fighter II — Capcom (Yoshiki Okamoto & Akira Nishitani) (1991, game). Special moves are input sequences that must be entered within a timing window, the training mode shows the live buffer of recent inputs, and the game forgives slightly sloppy execution. In the UI, shortcuts become key sequences with a per-step window, a live input buffer, a continuation list that appears when you hesitate, and deliberate tolerance for Caps Lock, keyboard layouts and stray keys.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
timeout | time (ms number, "1.5s", "900ms") | 1500 | Timing window between two steps. It restarts on each key, so it is per step, not for the whole sequence. Stops entirely while the continuation panel is shown (unless the buffer is already a complete move waiting for a longer one). |
hint-delay | time | "none" | 700 | Pause mid-sequence after which the continuation panel appears. "none" never shows it (the window then always applies). |
help | keys | "none" | ? | Keys that open the cheat sheet. It is the only single character key allowed without single-keys; it obeys the character-key switch and can be remapped like any move. "none" removes it (call openSheet() from a button). |
display | buffer | history | none | buffer | buffer: the keys of the current sequence, then what they did. history: the same plus the last four moves stacked for a few seconds (screencasts, onboarding). none: no buffer (the continuation panel still follows hint-delay). |
placement | bottom | bottom-start | bottom-end | top | top-start | top-end | bottom | Where the buffer and panel sit (viewport, or the positioned parent with contained). |
contained | boolean | The buffer is positioned inside the nearest positioned ancestor (an app frame, a demo) instead of the viewport. | |
platform | auto | mac | windows | linux | auto | How keys are displayed (⌘ ⌥ ⇧ ⌃ vs Ctrl Alt Shift Win). Display only: Mod always matches the real platform’s key. |
scope | CSS selector | Only keys pressed while focus is inside a matching element count, and [data-combo] elements outside it are ignored. Default: the whole document. | |
single-keys | boolean | Allows one-step character shortcuts (“c”, “/”). Off by default: they are ignored with a console warning (WCAG 2.1.4). Even when allowed they obey the character-key switch. | |
in-fields | boolean | Moves may fire while typing in fields. Per move: in-fields on <mv-combo-move>, data-combo-in-fields, register({ inFields }). Even then, only sequences starting with a real modifier chord, Escape or a function key fire in a field, never plain letters. | |
character-keys | on | off | on | Initial state of the character-key switch (shortcuts made only of letters, digits or symbols). The user’s choice in the cheat sheet, or the characterKeys property, overrides it. |
persist | string | Saves remaps and the character-key choice in localStorage under mv-combo:<value> ("default" when empty). Omit it and use the remaps / characterKeys properties to store them server-side. | |
disabled | boolean | Global off switch: no key is consumed, nothing shows. | |
quiet | boolean | Don’t announce the label of executed moves in the polite live region. | |
<mv-combo-move keys label group name href target for in-fields disabled> | child element | Declarative move. keys: sequence, alternatives separated by commas ("g i, Mod+Shift+i"). label: shown in the panel and sheet. name: stable id for remaps (default: label slug). href (+ target): navigates. for: selector of an element to focus (fields) or click. Neither: handle it in mv-combo. | |
data-combo / data-combo-label / data-combo-group / data-combo-name / data-combo-action / data-combo-in-fields | on any element | Makes the element a move: it is clicked (or focused for fields; data-combo-action="focus | click" forces it). Active only while it is rendered, enabled and not inert, so contextual shortcuts follow the UI. Label: data-combo-label, aria-label, then its text. data-combo-group also works on a container. Single-step chords get aria-keyshortcuts. | |
data-combo-hint | on any element (usually <kbd>) | Filled with the current keycaps of the move it belongs to: its closest [data-combo] element, or the move id given as value. Follows remaps and platform; hidden when the move is switched off. data-variant, data-size, data-then (show “then” between steps) are passed to the keycaps. | |
data-combo-ignore | on any element | Keys pressed inside it are never taken (like fields, [role=menu|listbox|grid|tree|textbox|combobox|slider|spinbutton|application]). |
Properties
| Name | Type | Description |
|---|---|---|
characterKeys | boolean | The WCAG 2.1.4 off switch for character-key shortcuts. Persisted with persist. |
remaps | Record<moveId, keys> | User remaps. Set it from your own storage; read it after mv-combo-change. |
moves | Array<{ id, label, group, keys, defaultKeys, display, custom, source, element, active }> | Snapshot of every declared move (read-only). source: "move" | "element" | "register" | "help". |
conflicts | Array<{ keys, moves: [id, id] }> | Identical sequences declared twice (the first one wins; a console warning is printed once). Prefixes are not conflicts. |
buffer | string | Keys typed so far in the current sequence ("g p"), "" when idle. |
sheetOpen | boolean | True while the cheat sheet is open. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (sheetTitle, sheetText, filter, filterPlaceholder, close, empty, then, or, general, help, change, reset, custom, off, record, recordEmpty, recordHint, recordHintSpoken, recordNeedKeys, recordSingle, recordConflict, recordSaved, recordRestored, characterKeys, characterKeysHint, resetAll, restoredAll, hintsTitle, hintsCount, hintsCountOne, more, cancel, runNow, allShortcuts, miss). English defaults. |
timeout / hintDelay / help / display / placement / platform / scope / persist / singleKeys / inFields / disabled / contained / quiet | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
register({ keys, label, id?, group?, run?, href?, inFields?, when? }) | Adds a move from JS; run(detail) is called unless mv-combo is canceled, when() returning false makes it inactive. Returns an unregister function. |
unregister(id) | Removes a registered move. |
remap(id, keys) / resetRemaps() | Changes a move’s keys (null or "" restores the default); returns false for invalid keys. resetRemaps() restores every default. |
press(keys) | Feeds keys as if typed, one step or a whole sequence ("g", later "p"): onboarding tours, demos, tests. Returns true if consumed. |
trigger(id) | Runs a move by id without typing it (from a menu or a command palette); emits mv-combo with source "api". |
cancel() | Clears the current sequence. |
format(keys) | Display string on the current platform: "Mod+k" → "⌘K" or "Ctrl+K", "g i" → "G then I". |
openSheet() / closeSheet() | Opens or closes the cheat sheet (also reachable with the help keys). |
Events
| Name | Description |
|---|---|
mv-combo | A move matched. Cancelable: preventDefault() skips the default action (navigation, click, focus, run()), e.g. to route in a SPA. detail: { id, label, group, keys (the matched sequence), source: "keyboard" | "api", element, href }. |
mv-combo-miss | A started sequence led nowhere. detail: { keys, reason: "mismatch" | "timeout" }. Useful to spot shortcuts users expect. |
mv-combo-remap | The user is about to save a new shortcut or restore defaults in the sheet (cancelable). detail: { id, label, keys, previous, reset }. |
mv-combo-change | User settings changed in the sheet. detail: { characterKeys, remaps }: store them if you don’t use persist. |
mv-combo-sheet | The cheat sheet opened or closed. detail: { open }. |
Content structure
| Name | Description |
|---|---|
(children) | <mv-combo-move> elements (display: none). The buffer, a live region and the sheet <dialog> are appended by the component. |
CSS classes
| Name | Description |
|---|---|
mv-combo-hud | Fixed (or contained) overlay, aria-hidden, pointer-events: none. data-state="idle | typing | match | miss", data-held while the clock is stopped, data-placement, data-display. |
mv-combo-buffer | The input display: .mv-combo-strokes (keycaps, .mv-combo-stroke each), .mv-combo-result (-icon, -text), .mv-combo-window (draining timing line). |
mv-combo-hints | Continuation panel: -hints-head (-hints-count), -hints-list > .mv-combo-hint (-hint-label; data-more), -hints-foot. |
mv-combo-history | Recent moves in display="history": .mv-combo-history-item (data-leaving while fading). |
mv-combo-sheet | The cheat-sheet <dialog>: -sheet-head, -sheet-title, -sheet-text, -close, -sheet-tools (.mv-combo-filter), -sheet-body, .mv-combo-group (-group-title), .mv-combo-row (data-custom, data-off, data-recording; -row-label, -tag, -row-keys, -row-actions, -row-btn), -sheet-foot (.mv-combo-chars, -reset-all). |
mv-combo-recorder | Shortcut recorder shown in a row while remapping: -recorder-input (readonly, carries the spoken value), -recorder-keys, -recorder-msg (data-tone="error"). |
mv-combo-keys / mv-combo-chord / mv-combo-then | Rendered sequences: .mv-kbd keycaps grouped per chord, “then” between steps (also inside [data-combo-hint]). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-combo-accent | var(--mv-accent) | Caret, timing line, match tint and check. |
--mv-combo-offset | 1.25rem | Distance of the buffer from the edge. |
--mv-combo-sheet-width | 48rem | Maximum width of the cheat sheet. |
--mv-combo-record | var(--mv-danger) | Recording dot in the recorder. |
Accessibility
Designed not to steal keys: one-step character shortcuts are refused unless single-keys is set, keys are ignored while composing (IME), in text fields, contenteditable and widgets that own letters or arrows (menu, listbox, grid, tree, combobox, textbox, slider, spinbutton, application, [data-combo-ignore]), and even with in-fields a field only receives modifier chords, Escape or function keys. WCAG 2.1.4 is met twice: the cheat sheet has a “Character-key shortcuts” switch (also characterKeys, character-keys="off") and every shortcut can be remapped, both saved per user; disabled is a global off switch. Timing (WCAG 2.2.1): the window applies per step and stops as soon as the continuation panel appears, so a sequence waits for the user indefinitely; Escape, a click or leaving the tab cancels it. The buffer and panel are aria-hidden and pointer-events: none (purely visual echo); the label of each executed move is announced once in a polite live region (quiet turns it off, and a canceled mv-combo is not announced). The cheat sheet is a native modal <dialog> labelled and described by its heading: focus goes to the filter field, Escape or the close button closes it and focus returns to where it was. Each row reads its label and a spoken version of the keys (“Command K”, “G then I”; symbols named) while the keycaps are aria-hidden; edit and restore buttons have explicit names (“Change shortcut for Go to inbox”). Remapping uses a readonly text field (screen readers switch to focus mode, so keys reach it) whose value is the spoken sequence, described by the instructions, with each key and any conflict announced politely; Enter saves, Escape cancels without closing the dialog, Backspace erases, and Tab always leaves (never a keyboard trap). [data-combo] elements get aria-keyshortcuts for single-step chords (sequences cannot be expressed there), and [data-combo-hint] keycaps carry the same spoken text. Reduced motion (OS or data-motion="reduce"): no key pop, shake, blinking caret or draining line. Forced colors: system colors for borders, the caret and the match state.