Exclusivobeta

Combo <mv-combo>

Motor de atajos de teclado en secuencia para apps de usuarios avanzados, con un búfer de entrada en vivo. Los movimientos son secuencias de pasos (“g i”, “g p a”), cada uno una tecla o un acorde (“Mod+k”, “Alt+ArrowDown”; Mod es ⌘ en Apple y Ctrl en los demás sistemas), declarados con <mv-combo-move keys href|for label group>, con data-combo en cualquier elemento (el movimiento hace clic en él o enfoca un campo) o con register({ keys, label, run }). Cada paso debe seguir al anterior dentro de una ventana de tiempo (1,5 s por defecto) que una línea fina va vaciando en un pequeño búfer aria-hidden que muestra las teclas pulsadas hasta el momento; tras una breve pausa a mitad de secuencia, un panel de continuación enumera lo que puede seguir, filtrado en vivo, y detiene el reloj para que quienes teclean despacio nunca pierdan la secuencia. Tolerancia incorporada: Caps Lock se ignora, las distribuciones no latinas y Option de macOS coinciden por tecla física, una tecla suelta reinicia la secuencia a partir de ella misma, Backspace elimina el último paso. Un movimiento que también es prefijo de otro (“g p” y “g p a”) espera a que pase la ventana, o se ejecuta al instante con Enter. mv-combo es cancelable (enrutamiento SPA); ? abre una hoja de referencia <dialog> generada a partir de los movimientos activos, donde cada atajo puede reasignarse grabando nuevas teclas (conflictos detectados, guardado con persist) y los atajos de teclas de carácter pueden desactivarse. Las teclas se muestran según la plataforma (⌘⇧P frente a Ctrl+Shift+P), los elementos [data-combo-hint] se pintan con las teclas actuales, y escribir en campos, menús o cuadrículas nunca se secuestra.

CategoríaUtilidades
TipoWeb Component (<mv-combo>)
Estadobeta
KitNavegación para usuarios avanzados
También instalabutton, 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

  • 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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["combo"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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.

Uso

Inicio rápido, el marcado mínimo que funciona:

<mv-combo>
  <mv-combo-move keys="g i" label="Go to inbox" href="/inbox"></mv-combo-move>
  <mv-combo-move keys="g s" label="Go to settings" href="/settings"></mv-combo-move>
</mv-combo>

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

<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 to 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>

Referencia cultural

Street Fighter II, Capcom (Yoshiki Okamoto & Akira Nishitani) (1991, videojuego). Los movimientos especiales son secuencias de comandos que deben introducirse dentro de una ventana de tiempo, el modo de entrenamiento muestra el búfer en vivo de las últimas entradas, y el juego perdona una ejecución algo imprecisa. En la interfaz, los atajos se convierten en secuencias de teclas con una ventana por paso, un búfer de entrada en vivo, una lista de continuaciones que aparece cuando dudas y una tolerancia deliberada con Caps Lock, las distribuciones de teclado y las teclas sueltas.

API

Attributes

NameTipoDefaultDescription
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

NameTipoDescription
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.

Esta página se tradujo con IA. Informar de un problema de traducción