useHotKey
Bind one or more keyboard shortcuts to a single handler. A shortcut is a string such as 's', 'mod+s' or 'mod+shift+k'; the mod token resolves to ⌘ on macOS and Ctrl everywhere else, so a single shortcut covers both platforms. Listens on window by default, or on a scoped target. Built on top of useEventListener, so the listener is detached automatically when the component scope is disposed; a stop function is returned to detach manually.
Demo
Click this panel to focus it, then try the shortcuts below.
- ⌘/Ctrl + S
- 0× saved
- ⌘/Ctrl + Enter
- submit
- ?
- help
- No shortcuts yet.
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { useHotKey } from '@basmilius/common';
const panel = useTemplateRef<HTMLDivElement>('panel');
const log = ref<string[]>([]);
const saves = ref(0);
function push(message: string): void {
log.value = [message, ...log.value].slice(0, 5);
}
useHotKey('mod+s', () => {
saves.value++;
push('Saved');
}, {target: panel});
useHotKey('mod+enter', () => {
push('Submitted');
}, {target: panel});
useHotKey('?', () => {
push('Help');
}, {target: panel});
</script>
<template>
<div ref="panel" class="demo" tabindex="0">
<p class="hint">Click this panel to focus it, then try the shortcuts below.</p>
<dl class="readout">
<div>
<dt>⌘/Ctrl + S</dt>
<dd>{{ saves }}× saved</dd>
</div>
<div>
<dt>⌘/Ctrl + Enter</dt>
<dd>submit</dd>
</div>
<div>
<dt>?</dt>
<dd>help</dd>
</div>
</dl>
<ul class="events">
<li v-for="(entry, index) in log" :key="index">{{ entry }}</li>
<li v-if="log.length === 0" class="empty">No shortcuts yet.</li>
</ul>
</div>
</template>
<style scoped>
.demo {
padding: 24px;
border: 1px solid var(--vp-c-border);
border-radius: 12px;
background: var(--vp-c-bg-soft);
outline: none;
transition: border-color .2s, background .2s;
user-select: none;
}
.demo:focus {
border-color: var(--vp-c-brand-1);
}
.hint {
margin: 0 0 16px;
color: var(--vp-c-text-2);
font-size: 14px;
}
.readout {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 0 0 16px;
}
.readout div {
padding: 12px;
border-radius: 8px;
background: var(--vp-c-bg);
text-align: center;
}
.readout dt {
margin-bottom: 4px;
color: var(--vp-c-text-3);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .04em;
}
.readout dd {
margin: 0;
font-family: var(--vp-font-family-mono);
font-size: 16px;
font-weight: 600;
color: var(--vp-c-brand-1);
}
.events {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
font-family: var(--vp-font-family-mono);
font-size: 14px;
}
.events .empty {
color: var(--vp-c-text-3);
}
</style>Importing
import { useHotKey } from '@basmilius/common';Usage
<script setup lang="ts">
import { useHotKey } from '@basmilius/common';
useHotKey('mod+s', evt => {
console.log('save', evt);
});
useHotKey(['mod+k', 'mod+shift+k'], () => {
console.log('open command palette');
});
</script>Pass a single shortcut or an array of shortcuts that all trigger the same handler. By default the handler is bound to window, which is convenient for app-wide shortcuts. Provide a target — a template ref, raw HTMLElement, window or document — to scope the shortcut to a specific element.
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { useHotKey } from '@basmilius/common';
const dialog = useTemplateRef<HTMLDialogElement>('dialog');
useHotKey('Escape', close, {target: dialog});
</script>Modifiers and keys
Combine tokens with +. Recognised modifiers are ctrl, meta (aliases cmd/command), shift, alt (aliases option/opt) and mod (⌘ on macOS, Ctrl elsewhere). Modifiers must match exactly, so 's' only fires for a bare S and never for ⌘S. Keys are matched case-insensitively against KeyboardEvent.key, with aliases for common keys: esc, space, up/down/left/right, enter/return, del and plus.
Single-character keys already carry their shifted character in KeyboardEvent.key — ? is Shift + / — so the Shift state is ignored for them unless you write shift explicitly. That means '?' matches without spelling it out as 'shift+?'.
Options
const stop = useHotKey('mod+s', save, {
enabled: isEditing,
preventDefault: true
});
stop();target— where to listen; defaults towindow.enabled— a ref or getter to toggle the shortcut without detaching the listener.event—'keydown'(default) or'keyup'.preventDefault— callsevt.preventDefault()on a match (defaulttrue), so⌘Swon't open the browser save dialog.stopPropagation— callsevt.stopPropagation()on a match (defaultfalse).ignoreWhileTyping— ignores matches while an<input>,<textarea>,<select>or contenteditable element is focused (defaulttrue). Shortcuts that includectrl,metaormodkeep firing so combinations like⌘Sstill work in a form field.repeat— fire repeatedly while the key is held down (defaultfalse).
Global shortcuts and typing
A shortcut bound to window works regardless of which element is focused, which is exactly what you want for app-wide shortcuts. ignoreWhileTyping keeps a bare key such as k from firing while the user is typing in a form field, and enabled lets you switch the shortcut on and off reactively. Try pressing K below — both on the page and inside the input — and toggle the checkbox:
<script setup lang="ts">
import { ref } from 'vue';
import { useHotKey } from '@basmilius/common';
const enabled = ref(true);
const presses = ref(0);
useHotKey('k', () => {
presses.value++;
}, {
enabled,
preventDefault: false
});
</script>preventDefault is set to false here so typing the letter k elsewhere keeps working; leave it on its default for modifier shortcuts like ⌘S where you do want to suppress the browser's own action.
Repeating while held
By default a shortcut fires once per key press, even when the key stays down. Set repeat to true to keep firing on the browser's auto-repeat — handy for steppers, nudging a value or moving through a list. Click the panel to focus it, then hold ↑ and ↓ to compare:
Click this panel to focus it, then press and hold the arrow keys.
- ↑ — repeat: false
- 0
- ↓ — repeat: true
- 0
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { useHotKey } from '@basmilius/common';
const panel = useTemplateRef<HTMLDivElement>('panel');
const once = ref(0);
const repeated = ref(0);
useHotKey('up', () => {
once.value++;
}, {target: panel});
useHotKey('down', () => {
repeated.value++;
}, {target: panel, repeat: true});
</script>
<template>
<div ref="panel" class="demo" tabindex="0">
<p class="hint">Click this panel to focus it, then press and hold the arrow keys.</p>
<dl class="readout">
<div>
<dt>↑ — repeat: false</dt>
<dd>{{ once }}</dd>
</div>
<div>
<dt>↓ — repeat: true</dt>
<dd>{{ repeated }}</dd>
</div>
</dl>
</div>
</template>
<style scoped>
.demo {
padding: 24px;
border: 1px solid var(--vp-c-border);
border-radius: 12px;
background: var(--vp-c-bg-soft);
outline: none;
transition: border-color .2s, background .2s;
user-select: none;
}
.demo:focus {
border-color: var(--vp-c-brand-1);
}
.hint {
margin: 0 0 16px;
color: var(--vp-c-text-2);
font-size: 14px;
}
.readout {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin: 0;
}
.readout div {
padding: 12px;
border-radius: 8px;
background: var(--vp-c-bg);
text-align: center;
}
.readout dt {
margin-bottom: 4px;
color: var(--vp-c-text-3);
font-family: var(--vp-font-family-mono);
font-size: 12px;
}
.readout dd {
margin: 0;
font-family: var(--vp-font-family-mono);
font-size: 24px;
font-weight: 600;
color: var(--vp-c-brand-1);
}
</style><script setup lang="ts">
import { ref } from 'vue';
import { useHotKey } from '@basmilius/common';
const value = ref(0);
useHotKey('up', () => value.value++, {repeat: true});
useHotKey('down', () => value.value--, {repeat: true});
</script>Type signature
type EligibleTarget = HTMLElement | ComponentPublicInstance | Window | Document;
type UseHotKeyOptions = {
readonly target?: MaybeRefOrGetter<EligibleTarget | null | undefined>;
readonly enabled?: MaybeRefOrGetter<boolean>;
readonly event?: 'keydown' | 'keyup';
readonly preventDefault?: boolean;
readonly stopPropagation?: boolean;
readonly ignoreWhileTyping?: boolean;
readonly repeat?: boolean;
};
declare function useHotKey(
shortcuts: string | string[],
handler: (evt: KeyboardEvent) => void,
options?: UseHotKeyOptions
): () => void;