Vim and Gmail-style key sequences

g then g to jump to the top, g then i to go to the inbox — a sequence is a different thing from a chord, and needs its own syntax and its own rules for what counts as "pressed in order."

Sequence vs. chord

A chord ("ctrl+a") is keys held down together, resolved from a single KeyboardEvent. A sequence ("g,o") is keys pressed one after another, within a timeout of each other — two completely different things bind-keyboard represents with two different syntaxes rather than overloading one.

Registering a sequence

A comma-separated key combination string is a sequence — its callback only fires once every step is pressed in order, within sequenceTimeout ms of each other (a constructor option, 1000ms by default):

shortcuts.ts
import { BindKeyboard } from "bind-keyboard";

const bindKeyboard = new BindKeyboard({ sequenceTimeout: 1000 });

bindKeyboard.add("g,g", () => window.scrollTo({ top: 0 }));
bindKeyboard.add("g,i", goToInbox);

A literal comma is unambiguous

A comma right after a + is a literal comma key, not a separator — so "ctrl+," (a real Ctrl+Comma binding, e.g. "open settings") and "ctrl+,,g" (a two-step sequence: Ctrl+Comma, then g) are never ambiguous with each other.

Holding a key doesn't complete a same-key sequence

OS key auto-repeat (holding g down, which fires repeated keydowns for the same physical press) is ignored for sequence matching — so simply holding g doesn't accidentally complete "g,g" on its own. It has to be released and pressed again for real.

Two sequences that could both complete on the same press

Registering both "g,o" and "g,o,x" means a press of g then o completes the first while the second is still legitimately pending on the same prefix — an ambiguous case most libraries resolve by picking whichever one arbitrarily. bind-keyboard fires neither in that instant, with a debug -only warning naming both, rather than guessing which one you meant:

shortcuts.ts
bindKeyboard.add("g,o", goToOverview);
bindKeyboard.add("g,o,x", goToOverviewExtended);
// Pressing g, then o: neither fires yet (ambiguous).
// Pressing x next: "g,o,x" is no longer ambiguous — it fires.

Letting a plain binding wait to see if it's part of a sequence

By default, a plain binding and a sequence sharing a starting key ("g" and "g,o") coexist — the plain one fires immediately, same as always. The per-binding option { deferForSequence: true } opts a plain binding out of that: it waits up to sequenceTimeout to see whether the press was the start of a sequence, firing only if it wasn't (as soon as that's known, not necessarily the full timeout):

shortcuts.ts
bindKeyboard.add("g", showGoToMenuHint, true, "keydown", {
  deferForSequence: true,
});
bindKeyboard.add("g,i", goToInbox);

Scoped and input-safe like any other binding

Sequences support everything a plain binding does — scope, allowInInputElements, description, override: false — so a sequence can be scoped to a modal exactly the way described in keyboard shortcuts inside a modal.

Related

Migrating from Mousetrap or hotkeys-js · Keyboard shortcuts inside a modal