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.

CategoryUtilities
TypeWeb Component (<mv-combo>)
Statusbeta
Also installsbutton, input, kbd, switch, checkbox
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs combo --out ./src/marvelous

AI 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

NameTypeDefaultDescription
timeouttime (ms number, "1.5s", "900ms")1500Timing 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-delaytime | "none"700Pause mid-sequence after which the continuation panel appears. "none" never shows it (the window then always applies).
helpkeys | "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).
displaybuffer | history | nonebufferbuffer: 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).
placementbottom | bottom-start | bottom-end | top | top-start | top-endbottomWhere the buffer and panel sit (viewport, or the positioned parent with contained).
containedbooleanThe buffer is positioned inside the nearest positioned ancestor (an app frame, a demo) instead of the viewport.
platformauto | mac | windows | linuxautoHow keys are displayed (⌘ ⌥ ⇧ ⌃ vs Ctrl Alt Shift Win). Display only: Mod always matches the real platform’s key.
scopeCSS selectorOnly keys pressed while focus is inside a matching element count, and [data-combo] elements outside it are ignored. Default: the whole document.
single-keysbooleanAllows 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-fieldsbooleanMoves 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-keyson | offonInitial 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.
persiststringSaves 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.
disabledbooleanGlobal off switch: no key is consumed, nothing shows.
quietbooleanDon’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 elementDeclarative 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-fieldson any elementMakes 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-hinton 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-ignoreon any elementKeys pressed inside it are never taken (like fields, [role=menu|listbox|grid|tree|textbox|combobox|slider|spinbutton|application]).

Properties

NameTypeDescription
characterKeysbooleanThe WCAG 2.1.4 off switch for character-key shortcuts. Persisted with persist.
remapsRecord<moveId, keys>User remaps. Set it from your own storage; read it after mv-combo-change.
movesArray<{ id, label, group, keys, defaultKeys, display, custom, source, element, active }>Snapshot of every declared move (read-only). source: "move" | "element" | "register" | "help".
conflictsArray<{ keys, moves: [id, id] }>Identical sequences declared twice (the first one wins; a console warning is printed once). Prefixes are not conflicts.
bufferstringKeys typed so far in the current sequence ("g p"), "" when idle.
sheetOpenbooleanTrue while the cheat sheet is open.
stringsPartial<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 / quietreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-comboA 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-missA started sequence led nowhere. detail: { keys, reason: "mismatch" | "timeout" }. Useful to spot shortcuts users expect.
mv-combo-remapThe user is about to save a new shortcut or restore defaults in the sheet (cancelable). detail: { id, label, keys, previous, reset }.
mv-combo-changeUser settings changed in the sheet. detail: { characterKeys, remaps }: store them if you don’t use persist.
mv-combo-sheetThe cheat sheet opened or closed. detail: { open }.

Content structure

NameDescription
(children)<mv-combo-move> elements (display: none). The buffer, a live region and the sheet <dialog> are appended by the component.

CSS classes

NameDescription
mv-combo-hudFixed (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-bufferThe input display: .mv-combo-strokes (keycaps, .mv-combo-stroke each), .mv-combo-result (-icon, -text), .mv-combo-window (draining timing line).
mv-combo-hintsContinuation panel: -hints-head (-hints-count), -hints-list > .mv-combo-hint (-hint-label; data-more), -hints-foot.
mv-combo-historyRecent moves in display="history": .mv-combo-history-item (data-leaving while fading).
mv-combo-sheetThe 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-recorderShortcut 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-thenRendered sequences: .mv-kbd keycaps grouped per chord, “then” between steps (also inside [data-combo-hint]).

CSS variables

NameDefaultDescription
--mv-combo-accentvar(--mv-accent)Caret, timing line, match tint and check.
--mv-combo-offset1.25remDistance of the buffer from the edge.
--mv-combo-sheet-width48remMaximum width of the cheat sheet.
--mv-combo-recordvar(--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.