Skip to content

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

ts
import { useHotKey } from '@basmilius/common';

Usage

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

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

ts
const stop = useHotKey('mod+s', save, {
    enabled: isEditing,
    preventDefault: true
});

stop();
  • target — where to listen; defaults to window.
  • enabled — a ref or getter to toggle the shortcut without detaching the listener.
  • event'keydown' (default) or 'keyup'.
  • preventDefault — calls evt.preventDefault() on a match (default true), so ⌘S won't open the browser save dialog.
  • stopPropagation — calls evt.stopPropagation() on a match (default false).
  • ignoreWhileTyping — ignores matches while an <input>, <textarea>, <select> or contenteditable element is focused (default true). Shortcuts that include ctrl, meta or mod keep firing so combinations like ⌘S still work in a form field.
  • repeat — fire repeatedly while the key is held down (default false).

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:

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

ts
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;

See also