Skip to content

useEventListener

Attach an event listener to an element ref, a raw HTMLElement, window or document. The listener is bound when the target resolves, detached when the target changes or the component scope is disposed, and re-attached automatically when the target points at something new. A stop function is returned to detach manually.

Demo

Move, hover and click anywhere inside this box.

Pointer
0 × 0
Inside
no
Clicks
0

<script setup lang="ts">
    import { ref, useTemplateRef } from 'vue';
    import { useEventListener } from '@basmilius/common';

    const area = useTemplateRef<HTMLDivElement>('area');
    const position = ref({x: 0, y: 0});
    const inside = ref(false);
    const clicks = ref(0);

    useEventListener(area, 'pointermove', evt => {
        const rect = (evt.currentTarget as HTMLElement).getBoundingClientRect();
        position.value = {
            x: Math.round(evt.clientX - rect.left),
            y: Math.round(evt.clientY - rect.top)
        };
    });

    useEventListener(area, ['pointerenter', 'pointerleave'], evt => {
        inside.value = evt.type === 'pointerenter';
    });

    useEventListener(area, 'click', () => {
        clicks.value++;
    });
</script>

<template>
    <div ref="area" class="demo" :class="{active: inside}">
        <p class="hint">Move, hover and click anywhere inside this box.</p>

        <dl class="readout">
            <div>
                <dt>Pointer</dt>
                <dd>{{ position.x }} × {{ position.y }}</dd>
            </div>
            <div>
                <dt>Inside</dt>
                <dd>{{ inside ? 'yes' : 'no' }}</dd>
            </div>
            <div>
                <dt>Clicks</dt>
                <dd>{{ clicks }}</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);
        cursor: crosshair;
        transition: border-color .2s, background .2s;
        user-select: none;
    }

    .demo.active {
        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;
    }

    .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: 18px;
        font-weight: 600;
        color: var(--vp-c-brand-1);
    }
</style>

Global targets

window and document are valid targets too. Because reading window.innerWidth during component setup touches a browser global, the demo component is wrapped in VitePress' built-in <ClientOnly> so it only renders in the browser:

vue
<script setup lang="ts">
    import { ref } from 'vue';
    import { useEventListener } from '@basmilius/common';

    const width = ref(window.innerWidth);
    const height = ref(window.innerHeight);
    const lastKey = ref('—');

    useEventListener(window, 'resize', () => {
        width.value = window.innerWidth;
        height.value = window.innerHeight;
    });

    useEventListener(document, 'keydown', evt => {
        lastKey.value = evt.key;
    });
</script>

Importing

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

Usage

vue
<script setup lang="ts">
    import { ref, useTemplateRef } from 'vue';
    import { useEventListener } from '@basmilius/common';

    const button = useTemplateRef<HTMLButtonElement>('button');
    const clicks = ref(0);

    useEventListener(button, 'click', () => {
        clicks.value++;
    });

    useEventListener(window, ['resize', 'orientationchange'], () => {
        console.log('viewport changed');
    });
</script>

<template>
    <button ref="button">Clicked {{ clicks }} times</button>
</template>

The target may be a template ref, a raw HTMLElement, window or document. Element and component refs are unwrapped via unwrapTarget, so passing a component instance works as well.

Pass a single event name or an array of names. The third argument is the listener; the event object is typed against the combined DOM event maps, so evt is inferred from the event name. The optional fourth argument accepts the standard addEventListener options ({ passive: true }, { capture: true }, …).

ts
const stop = useEventListener(document, 'keydown', evt => {
    if (evt.key === 'Escape') {
        stop();
    }
});

Cleanup happens automatically on scope dispose; call the returned stop only when you want to detach earlier.

Type signature

ts
type EligibleTarget = HTMLElement | ComponentPublicInstance | Window | Document;
type EventMap = HTMLElementEventMap & WindowEventMap & DocumentEventMap;

declare function useEventListener<TType extends keyof EventMap>(
    target: MaybeRefOrGetter<EligibleTarget | null | undefined>,
    type: TType | TType[],
    listener: (evt: EventMap[TType]) => void,
    options?: boolean | AddEventListenerOptions
): () => void;

See also