Fiber UI LogoFiberUI

useEventListener

A type-safe React hook for attaching event listeners to window, document, or any HTML element. Handles cleanup automatically, supports all standard options, and is SSR-safe.

Installation

npx shadcn@latest add https://r.fiberui.com/r/hooks/use-event-listener.json

Features

  • Type-Safe - Full TypeScript inference for event types based on target.
  • Three Targets - Attach to window, document, or any HTML element.
  • SSR-Safe - Works with Next.js and other SSR frameworks.
  • Auto Cleanup - Listeners are removed on unmount automatically.
  • Options Support - Supports passive, capture, and once.

Source Code

View the full hook implementation in the Hook Source Code section below.

Learn More


Understanding the Three Overloads

This hook has three overloads depending on where you want to attach the listener. Each overload uses a different TypeScript interface for event type inference.

Quick Decision Guide

Which overload should I use?

Ask yourself: Where does this event naturally belong?

  • Window → Global events that aren't tied to a specific element (resize, scroll, keyboard shortcuts, online/offline)
  • Document → Page lifecycle events that belong to the document object (visibility, fullscreen)
  • Element → Events on a specific DOM element (click, hover, focus, form events)
TargetElement ParameterTypeScript InterfaceCommon Events
Windowundefined or nullWindowEventMapresize, scroll, keydown, online, offline, storage
Document"document" (string)DocumentEventMapvisibilitychange, fullscreenchange, selectionchange
ElementRefObject<HTMLElement>HTMLElementEventMapclick, mouseenter, focus, blur, submit, touchstart

Overload 1: Window Events

Listen to global window events like resize, scroll, or keyboard input. This is the default when no element is provided.

When to use Window events

Use window events when:

  • The event is global and not tied to a specific element
  • You need keyboard shortcuts that work anywhere on the page
  • You're tracking browser-level changes (resize, scroll, network status)

Window Resize

Track browser window dimensions in real-time.

Window Resize

Overload 1

Listening to resize on window. Resize your browser to see it update.

Width

0px

Height

0px

"use client";

import { useState, useEffect } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { Monitor } from "lucide-react";

/**
 * Example 1: Window Resize Event
 * Demonstrates Overload 1 - Window events (no element passed)
 */
export const Example1 = () => {
    const [size, setSize] = useState({ width: 0, height: 0 });

    // Overload 1: Window events - no element parameter needed
    useEventListener("resize", () => {
        setSize({
            width: window.innerWidth,
            height: window.innerHeight,
        });
    });

    // Initialize dimensions on mount
    useEffect(() => {
        setSize({
            width: window.innerWidth,
            height: window.innerHeight,
        });
    }, []);

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <Monitor className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Window Resize</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 1
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">resize</code> on{" "}
                <strong>window</strong>. Resize your browser to see it update.
            </p>

            <div className="bg-muted/50 grid grid-cols-2 gap-4 rounded-lg p-4">
                <div className="text-center">
                    <p className="text-muted-foreground text-xs uppercase">
                        Width
                    </p>
                    <p className="text-primary text-2xl font-bold">
                        {size.width}
                        <span className="text-muted-foreground text-sm">
                            px
                        </span>
                    </p>
                </div>
                <div className="text-center">
                    <p className="text-muted-foreground text-xs uppercase">
                        Height
                    </p>
                    <p className="text-primary text-2xl font-bold">
                        {size.height}
                        <span className="text-muted-foreground text-sm">
                            px
                        </span>
                    </p>
                </div>
            </div>
        </div>
    );
};
useEventListener("resize", (event) => {
    console.log(window.innerWidth, window.innerHeight);
});

MDN Reference: resize event

Global Keyboard Events

Capture keyboard shortcuts globally without needing an element to be focused.

Keyboard Events

Overload 1

Listening to keydown on window. Press any key or combination.

Press any key...
"use client";

import { useState, useCallback } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { Keyboard } from "lucide-react";

/**
 * Example 4: Keyboard Events
 * Demonstrates Overload 1 - Window events for global keyboard input
 */
export const Example4 = () => {
    const [lastKey, setLastKey] = useState<string | null>(null);
    const [history, setHistory] = useState<string[]>([]);

    const handleKeyDown = useCallback((event: KeyboardEvent) => {
        const parts: string[] = [];
        if (event.metaKey || event.ctrlKey) parts.push("");
        if (event.shiftKey) parts.push("");
        if (event.altKey) parts.push("");

        const key =
            event.key.length === 1 ? event.key.toUpperCase() : event.key;
        if (!["Control", "Shift", "Alt", "Meta"].includes(event.key)) {
            parts.push(key);
        }

        const combo = parts.join("+");
        if (combo) {
            setLastKey(combo);
            setHistory((prev) => [combo, ...prev.slice(0, 4)]);
        }
    }, []);

    // Overload 1: Window events - no element needed for global keyboard
    useEventListener("keydown", handleKeyDown);

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <Keyboard className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Keyboard Events</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 1
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">keydown</code> on{" "}
                <strong>window</strong>. Press any key or combination.
            </p>

            <div className="bg-muted/50 flex h-24 items-center justify-center rounded-lg">
                {lastKey ? (
                    <kbd className="bg-background rounded-lg border px-4 py-2 text-2xl font-bold shadow-sm">
                        {lastKey}
                    </kbd>
                ) : (
                    <span className="text-muted-foreground">
                        Press any key...
                    </span>
                )}
            </div>

            {history.length > 0 && (
                <div className="flex flex-wrap gap-2">
                    {history.map((key, i) => (
                        <kbd
                            key={i}
                            className="bg-muted rounded px-2 py-1 text-xs"
                        >
                            {key}
                        </kbd>
                    ))}
                </div>
            )}
        </div>
    );
};
useEventListener("keydown", (event) => {
    console.log(event.key);
});

MDN Reference: keydown event

Scroll with Passive Option

Use { passive: true } for scroll events to improve performance by telling the browser you won't call preventDefault().

Scroll with Options

Overload 1

Listening to scroll on window with passive: true for performance.

Scroll Y0px
DirectionIDLE
Progress0%
"use client";

import { useState, useRef } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { ArrowDown } from "lucide-react";

/**
 * Example 6: Scroll with Passive Option
 * Demonstrates Overload 1 - Window events with options parameter
 */
export const Example6 = () => {
    const [scrollY, setScrollY] = useState(0);
    const lastY = useRef(0);
    const [direction, setDirection] = useState<"up" | "down" | null>(null);

    // Overload 1 with options: { passive: true } for better scroll performance
    useEventListener(
        "scroll",
        () => {
            const y = window.scrollY;
            setDirection(
                y > lastY.current ? "down" : y < lastY.current ? "up" : null,
            );
            lastY.current = y;
            setScrollY(y);
        },
        null,
        { passive: true }, // <-- Options parameter
    );

    const progress =
        typeof window !== "undefined"
            ? Math.min(
                  100,
                  Math.round(
                      (scrollY /
                          (document.documentElement.scrollHeight -
                              window.innerHeight)) *
                          100,
                  ) || 0,
              )
            : 0;

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <ArrowDown
                    className={`text-primary h-5 w-5 transition-transform ${
                        direction === "up" ? "rotate-180" : ""
                    }`}
                />
                <h3 className="font-semibold">Scroll with Options</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 1
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">scroll</code> on{" "}
                <strong>window</strong> with{" "}
                <code className="bg-muted rounded px-1">passive: true</code> for
                performance.
            </p>

            <div className="bg-muted/50 space-y-3 rounded-lg p-4">
                <div className="flex items-center justify-between">
                    <span className="text-sm">Scroll Y</span>
                    <span className="font-mono text-sm">{scrollY}px</span>
                </div>

                <div className="flex items-center justify-between">
                    <span className="text-sm">Direction</span>
                    <span
                        className={`rounded-full px-2 py-1 text-xs font-medium ${
                            direction === "down"
                                ? "bg-blue-500/20 text-blue-500"
                                : direction === "up"
                                  ? "bg-green-500/20 text-green-500"
                                  : "bg-muted text-muted-foreground"
                        }`}
                    >
                        {direction?.toUpperCase() || "IDLE"}
                    </span>
                </div>

                <div className="space-y-1">
                    <div className="flex justify-between text-sm">
                        <span>Progress</span>
                        <span className="font-mono">{progress}%</span>
                    </div>
                    <div className="bg-muted h-2 overflow-hidden rounded-full">
                        <div
                            className="bg-primary h-full transition-all"
                            style={{ width: `${progress}%` }}
                        />
                    </div>
                </div>
            </div>
        </div>
    );
};
useEventListener(
    "scroll",
    (event) => {
        console.log(window.scrollY);
    },
    null,
    { passive: true },
);

Why use passive: true?

When passive: true, the browser doesn't wait for your handler to potentially call preventDefault(), which allows it to start scrolling immediately. This significantly improves scroll performance.

Learn more: Improving scroll performance with passive listeners

Online/Offline Detection

Detect network connectivity changes in real-time.

Network Status

Overload 1

Listening to online and offline on window. Toggle your network to test.

ConnectionOnline
"use client";

import { useState, useEffect } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { Wifi, WifiOff } from "lucide-react";

/**
 * Example 7: Online/Offline Detection
 * Demonstrates Overload 1 - Window events for network status
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/online_event
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
 */
export const Example7 = () => {
    const [isOnline, setIsOnline] = useState(true);
    const [lastChange, setLastChange] = useState<Date | null>(null);

    // Initialize on mount (SSR-safe)
    useEffect(() => {
        setIsOnline(navigator.onLine);
    }, []);

    // Overload 1: Window events - 'online' and 'offline' are WindowEventMap events
    useEventListener("online", () => {
        setIsOnline(true);
        setLastChange(new Date());
    });

    useEventListener("offline", () => {
        setIsOnline(false);
        setLastChange(new Date());
    });

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                {isOnline ? (
                    <Wifi className="text-primary h-5 w-5" />
                ) : (
                    <WifiOff className="text-destructive h-5 w-5" />
                )}
                <h3 className="font-semibold">Network Status</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 1
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">online</code> and{" "}
                <code className="bg-muted rounded px-1">offline</code> on{" "}
                <strong>window</strong>. Toggle your network to test.
            </p>

            <div className="bg-muted/50 flex items-center justify-between rounded-lg p-4">
                <span className="text-sm">Connection</span>
                <span
                    className={`rounded-full px-3 py-1 text-sm font-medium ${
                        isOnline
                            ? "bg-green-500/20 text-green-500"
                            : "bg-red-500/20 text-red-500"
                    }`}
                >
                    {isOnline ? "Online" : "Offline"}
                </span>
            </div>

            {lastChange && (
                <p className="text-muted-foreground text-center text-xs">
                    Last changed: {lastChange.toLocaleTimeString()}
                </p>
            )}
        </div>
    );
};
useEventListener("online", () => setIsOnline(true));
useEventListener("offline", () => setIsOnline(false));

MDN Reference: online event | offline event

Cross-Tab Storage Sync

The storage event fires when localStorage changes in another tab - perfect for cross-tab communication.

Storage Event

Overload 1

Listening to storage on window. This event fires when localStorage changes in another tab.

Open this page in another tab, then run in DevTools:

localStorage.setItem("test", "hello")

No storage events yet

"use client";

import { useState } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { FolderSync } from "lucide-react";

/**
 * Example 10: Storage Event (Cross-Tab Sync)
 * Demonstrates Overload 1 - Window events for localStorage changes
 *
 * The 'storage' event fires when localStorage is modified in ANOTHER tab.
 * This enables cross-tab communication without WebSockets.
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
 */
export const Example10 = () => {
    const [events, setEvents] = useState<
        Array<{ key: string | null; value: string | null; time: string }>
    >([]);

    // Overload 1: Window events - 'storage' is a WindowEventMap event
    // Note: This event only fires when localStorage is changed in a DIFFERENT tab
    useEventListener("storage", (e) => {
        setEvents((prev) => [
            {
                key: e.key,
                value: e.newValue,
                time: new Date().toLocaleTimeString(),
            },
            ...prev.slice(0, 4),
        ]);
    });

    const simulateChange = () => {
        // This won't trigger our listener - it only fires from other tabs
        // But we can show the code pattern
        const key = "demo-key";
        const value = `value-${Date.now()}`;
        localStorage.setItem(key, value);
    };

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <FolderSync className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Storage Event</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 1
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">storage</code> on{" "}
                <strong>window</strong>. This event fires when localStorage
                changes in <em>another tab</em>.
            </p>

            <div className="bg-muted/50 space-y-2 rounded-lg p-4">
                <p className="text-muted-foreground text-xs">
                    Open this page in another tab, then run in DevTools:
                </p>
                <pre className="bg-muted overflow-x-auto rounded p-2 text-xs">
                    <code>{`localStorage.setItem("test", "hello")`}</code>
                </pre>
            </div>

            <button
                onClick={simulateChange}
                className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-4 py-2 text-sm transition-colors"
            >
                Set localStorage (same tab - won&apos;t trigger)
            </button>

            {events.length > 0 ? (
                <div className="space-y-2">
                    <p className="text-sm font-medium">Events received:</p>
                    {events.map((event, i) => (
                        <div
                            key={i}
                            className="bg-muted flex items-center justify-between rounded p-2 text-xs"
                        >
                            <code>
                                {event.key}: {event.value}
                            </code>
                            <span className="text-muted-foreground">
                                {event.time}
                            </span>
                        </div>
                    ))}
                </div>
            ) : (
                <p className="text-muted-foreground text-center text-sm">
                    No storage events yet
                </p>
            )}
        </div>
    );
};
useEventListener("storage", (e) => {
    if (e.key === "theme") {
        setTheme(e.newValue);
    }
});

Important

The storage event only fires when localStorage is modified in a different tab/window. Changes made in the same tab won't trigger this event.

MDN Reference: storage event


Overload 2: Document Events

Listen to document-level events by passing "document" as the third argument. This is useful for events like visibilitychange that only exist on the document.

When to use Document events

Use document events when:

  • The event is defined on the Document interface (not Window)
  • You need page lifecycle events (visibility, fullscreen)
  • TypeScript shows the event doesn't exist on WindowEventMap

Why use 'document' string?

Passing "document" instead of the actual document object:

  • Keeps your code SSR-safe (no document is not defined errors)
  • Avoids reference issues in different environments
  • Matches the pattern of other target options

Page Visibility

Detect when users switch tabs - useful for pausing videos, stopping animations, or tracking engagement.

Document Visibility

Overload 2

Listening to visibilitychange on document. Switch tabs to trigger it.

Page StatusVisible
Tab switches detected: 0
"use client";

import { useState } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { Eye, EyeOff } from "lucide-react";

/**
 * Example 3: Document Visibility Event
 * Demonstrates Overload 2 - Document events ("document" string)
 */
export const Example3 = () => {
    const [isVisible, setIsVisible] = useState(true);
    const [switches, setSwitches] = useState(0);

    // Overload 2: Document events - pass "document" string
    useEventListener(
        "visibilitychange",
        () => {
            const visible = document.visibilityState === "visible";
            setIsVisible(visible);
            setSwitches((c) => c + 1);
        },
        "document", // <-- String shorthand for document
    );

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                {isVisible ? (
                    <Eye className="text-primary h-5 w-5" />
                ) : (
                    <EyeOff className="text-muted-foreground h-5 w-5" />
                )}
                <h3 className="font-semibold">Document Visibility</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 2
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">visibilitychange</code>{" "}
                on <strong>document</strong>. Switch tabs to trigger it.
            </p>

            <div className="bg-muted/50 flex items-center justify-between rounded-lg p-4">
                <span className="text-sm">Page Status</span>
                <span
                    className={`rounded-full px-3 py-1 text-sm font-medium ${
                        isVisible
                            ? "bg-green-500/20 text-green-500"
                            : "bg-red-500/20 text-red-500"
                    }`}
                >
                    {isVisible ? "Visible" : "Hidden"}
                </span>
            </div>

            <div className="text-muted-foreground text-center text-sm">
                Tab switches detected: <strong>{switches}</strong>
            </div>
        </div>
    );
};
useEventListener(
    "visibilitychange",
    () => {
        if (document.visibilityState === "visible") {
            resumeVideo();
        }
    },
    "document",
);

MDN Reference: visibilitychange event | Page Visibility API


Overload 3: Element Events

Listen to events on a specific HTML element by passing a ref. TypeScript will correctly infer the event type based on the element.

When to use Element events

Use element events when:

  • You need events on a specific DOM element (not global)
  • You want scoped event handling (only this button, only this input)
  • You need access to element-specific event properties

Button Click

The most common use case - listening to clicks on a specific element.

Element Click

Overload 3

Listening to click on a button element via ref.

"use client";

import { useRef, useState } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { MousePointerClick } from "lucide-react";

/**
 * Example 2: Element Click Event
 * Demonstrates Overload 3 - Element events (ref passed)
 */
export const Example2 = () => {
    const buttonRef = useRef<HTMLButtonElement>(null);
    const [clicks, setClicks] = useState(0);

    // Overload 3: Element events - pass a ref
    useEventListener("click", () => setClicks((c) => c + 1), buttonRef);

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <MousePointerClick className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Element Click</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 3
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">click</code> on a{" "}
                <strong>button element</strong> via ref.
            </p>

            <button
                ref={buttonRef}
                className="bg-primary text-primary-foreground hover:bg-primary/90 h-20 rounded-lg text-lg font-medium transition-colors"
            >
                Clicked {clicks} times
            </button>
        </div>
    );
};
useEventListener(
    "click",
    () => {
        console.log("Button clicked!");
    },
    buttonRef,
);

MDN Reference: click event

Focus/Blur on Inputs

Track focus state on form elements - great for validation UX or custom styling.

Focus/Blur Events

Overload 3

Listening to focus and blur on an input element via ref.

StatusBlurred

Focus events: 0

"use client";

import { useRef, useState } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { Focus } from "lucide-react";

/**
 * Example 8: Focus/Blur on Input Element
 * Demonstrates Overload 3 - Element events on form inputs
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/focus_event
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event
 */
export const Example8 = () => {
    const inputRef = useRef<HTMLInputElement>(null);
    const [isFocused, setIsFocused] = useState(false);
    const [focusCount, setFocusCount] = useState(0);

    // Overload 3: Element events - TypeScript knows these are FocusEvent
    useEventListener(
        "focus",
        () => {
            setIsFocused(true);
            setFocusCount((c) => c + 1);
        },
        inputRef,
    );

    useEventListener("blur", () => setIsFocused(false), inputRef);

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <Focus className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Focus/Blur Events</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 3
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">focus</code> and{" "}
                <code className="bg-muted rounded px-1">blur</code> on an{" "}
                <strong>input element</strong> via ref.
            </p>

            <div className="space-y-3">
                <input
                    ref={inputRef}
                    type="text"
                    placeholder="Click to focus..."
                    className={`w-full rounded-lg border-2 bg-transparent px-4 py-3 outline-none transition-colors ${
                        isFocused
                            ? "border-primary bg-primary/5"
                            : "border-muted"
                    }`}
                />

                <div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
                    <span className="text-sm">Status</span>
                    <span
                        className={`rounded-full px-2 py-1 text-xs font-medium ${
                            isFocused
                                ? "bg-primary/20 text-primary"
                                : "bg-muted text-muted-foreground"
                        }`}
                    >
                        {isFocused ? "Focused" : "Blurred"}
                    </span>
                </div>

                <p className="text-muted-foreground text-center text-sm">
                    Focus events: <strong>{focusCount}</strong>
                </p>
            </div>
        </div>
    );
};
useEventListener("focus", () => setIsFocused(true), inputRef);
useEventListener("blur", () => setIsFocused(false), inputRef);

MDN Reference: focus event | blur event

Multiple Listeners on One Element

You can call useEventListener multiple times with the same ref to track different events.

Multiple Listeners

Overload 3

Three listeners on one element: mousemove, mouseenter, and mouseleave.

Hover here
Position: (0, 0)
"use client";

import { useRef, useState } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { MousePointer2 } from "lucide-react";

/**
 * Example 5: Multiple Listeners on Same Element
 * Demonstrates Overload 3 - Calling useEventListener multiple times with same ref
 */
export const Example5 = () => {
    const areaRef = useRef<HTMLDivElement>(null);
    const [position, setPosition] = useState({ x: 0, y: 0 });
    const [isHovering, setIsHovering] = useState(false);

    // Overload 3: Multiple listeners on the same ref
    useEventListener(
        "mousemove",
        (e) => setPosition({ x: e.offsetX, y: e.offsetY }),
        areaRef,
    );

    useEventListener("mouseenter", () => setIsHovering(true), areaRef);
    useEventListener("mouseleave", () => setIsHovering(false), areaRef);

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <MousePointer2 className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Multiple Listeners</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 3
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Three listeners on one element:{" "}
                <code className="bg-muted rounded px-1">mousemove</code>,{" "}
                <code className="bg-muted rounded px-1">mouseenter</code>, and{" "}
                <code className="bg-muted rounded px-1">mouseleave</code>.
            </p>

            <div
                ref={areaRef}
                className={`relative h-40 rounded-lg border-2 border-dashed transition-colors ${
                    isHovering ? "border-primary bg-primary/5" : "border-muted"
                }`}
            >
                {isHovering ? (
                    <div
                        className="bg-primary pointer-events-none absolute h-3 w-3 rounded-full"
                        style={{
                            left: position.x,
                            top: position.y,
                            transform: "translate(-50%, -50%)",
                        }}
                    />
                ) : (
                    <div className="text-muted-foreground flex h-full items-center justify-center">
                        Hover here
                    </div>
                )}
            </div>

            <div className="text-muted-foreground text-center text-sm">
                Position:{" "}
                <code className="font-mono">
                    ({position.x}, {position.y})
                </code>
            </div>
        </div>
    );
};
useEventListener("mousemove", trackMouse, areaRef);
useEventListener("mouseenter", () => setHover(true), areaRef);
useEventListener("mouseleave", () => setHover(false), areaRef);

Touch Events (Mobile)

Handle touch interactions with the passive option for better scroll performance on mobile.

Touch Events

Overload 3

Listening to touchstart, touchmove, and touchend with passive: true.

Touch here (mobile/tablet)

Touch count: 0

"use client";

import { useRef, useState } from "react";
import { useEventListener } from "@repo/hooks/dom/use-event-listener";
import { Smartphone } from "lucide-react";

/**
 * Example 9: Touch Events
 * Demonstrates Overload 3 - Element events with passive option for touch
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Touch_events
 * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#passive
 */
export const Example9 = () => {
    const touchAreaRef = useRef<HTMLDivElement>(null);
    const [touches, setTouches] = useState(0);
    const [position, setPosition] = useState({ x: 0, y: 0 });
    const [isTouching, setIsTouching] = useState(false);

    // Overload 3 with options: Element events with { passive: true }
    // passive: true improves scroll performance on touch devices
    useEventListener(
        "touchstart",
        (e) => {
            setIsTouching(true);
            setTouches((c) => c + 1);
            const touch = e.touches[0];
            if (touch && touchAreaRef.current) {
                const rect = touchAreaRef.current.getBoundingClientRect();
                setPosition({
                    x: Math.round(touch.clientX - rect.left),
                    y: Math.round(touch.clientY - rect.top),
                });
            }
        },
        touchAreaRef,
        { passive: true },
    );

    useEventListener(
        "touchmove",
        (e) => {
            const touch = e.touches[0];
            if (touch && touchAreaRef.current) {
                const rect = touchAreaRef.current.getBoundingClientRect();
                setPosition({
                    x: Math.round(touch.clientX - rect.left),
                    y: Math.round(touch.clientY - rect.top),
                });
            }
        },
        touchAreaRef,
        { passive: true },
    );

    useEventListener("touchend", () => setIsTouching(false), touchAreaRef, {
        passive: true,
    });

    return (
        <div className="flex w-full max-w-md flex-col gap-4">
            <div className="flex items-center gap-2">
                <Smartphone className="text-primary h-5 w-5" />
                <h3 className="font-semibold">Touch Events</h3>
                <span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
                    Overload 3
                </span>
            </div>

            <p className="text-muted-foreground text-sm">
                Listening to{" "}
                <code className="bg-muted rounded px-1">touchstart</code>,{" "}
                <code className="bg-muted rounded px-1">touchmove</code>, and{" "}
                <code className="bg-muted rounded px-1">touchend</code> with{" "}
                <code className="bg-muted rounded px-1">passive: true</code>.
            </p>

            <div
                ref={touchAreaRef}
                className={`relative flex h-40 items-center justify-center rounded-lg border-2 border-dashed transition-colors ${
                    isTouching ? "border-primary bg-primary/5" : "border-muted"
                }`}
            >
                {isTouching ? (
                    <>
                        <div
                            className="bg-primary pointer-events-none absolute h-4 w-4 rounded-full"
                            style={{
                                left: position.x,
                                top: position.y,
                                transform: "translate(-50%, -50%)",
                            }}
                        />
                        <span className="text-muted-foreground text-sm">
                            ({position.x}, {position.y})
                        </span>
                    </>
                ) : (
                    <span className="text-muted-foreground text-sm">
                        Touch here (mobile/tablet)
                    </span>
                )}
            </div>

            <p className="text-muted-foreground text-center text-sm">
                Touch count: <strong>{touches}</strong>
            </p>
        </div>
    );
};
useEventListener("touchstart", handleTouch, ref, { passive: true });

MDN Reference: Touch events | Using Touch Events


API Reference

Signature

// Overload 1: Window events (default)
useEventListener(eventName, handler);
useEventListener(eventName, handler, null);
useEventListener(eventName, handler, null, options);

// Overload 2: Document events
useEventListener(eventName, handler, "document");
useEventListener(eventName, handler, "document", options);

// Overload 3: Element events
useEventListener(eventName, handler, ref);
useEventListener(eventName, handler, ref, options);

Parameters

ParameterTypeDescription
eventNamestringThe event name (e.g., 'click', 'resize', 'keydown').
handler(event) => voidCallback invoked when the event fires. Event type is inferred automatically.
elementRefObject | "document" | nullTarget for the listener. Defaults to window if null or undefined.
optionsboolean | AddEventListenerOptionsStandard event listener options: capture, passive, once.

Options Explained

OptionTypeDescription
passivebooleanIf true, handler won't call preventDefault(). Improves scroll/touch performance.
capturebooleanIf true, handler runs during capture phase (parent → child) instead of bubble phase.
oncebooleanIf true, listener auto-removes after first invocation.

MDN Reference: EventTarget.addEventListener() options

Event Type Inference

The hook automatically infers the correct event type based on the target:

// Window event → e is UIEvent
useEventListener("resize", (e) => console.log(e.target));

// Window event → e is KeyboardEvent
useEventListener("keydown", (e) => console.log(e.key));

// Document event → e is Event
useEventListener("visibilitychange", (e) => console.log(e), "document");

// Element event → e is PointerEvent
useEventListener("click", (e) => console.log(e.clientX), buttonRef);

// Element event → e is FocusEvent
useEventListener("focus", (e) => console.log(e.relatedTarget), inputRef);

Common Patterns

Escape Key to Close Modal

const modalRef = useRef<HTMLDivElement>(null);

useEventListener("keydown", (e) => {
    if (e.key === "Escape" && isOpen) {
        onClose();
    }
});

Cmd/Ctrl + S to Save

useEventListener("keydown", (e) => {
    if ((e.metaKey || e.ctrlKey) && e.key === "s") {
        e.preventDefault();
        handleSave();
    }
});

Debounced Resize

import { useDebouncedCallback } from "@repo/hooks/utilities/use-debounced-callback";

const handleResize = useDebouncedCallback(() => {
    setDimensions({ width: window.innerWidth, height: window.innerHeight });
}, 100);

useEventListener("resize", handleResize);

TypeScript Interfaces Reference

Understanding which interface TypeScript uses helps you know what events are available:

InterfaceTargetDocumentation
WindowEventMapwindowMDN: Window events
DocumentEventMapdocumentMDN: Document events
HTMLElementEventMapHTML elementsMDN: HTMLElement events

How to find available events

In your IDE, type useEventListener(" and autocomplete will show all valid event names for the target you're using. This is powered by TypeScript's event map interfaces.


Events by Use Case

A comprehensive guide to choosing the right event for your use case.

User Input

Use CaseEventTargetNotes
Global keyboard shortcutskeydownWindowUse for app-wide shortcuts like Cmd+S
Keyboard input in fieldkeydown / keyupElementAttach to specific input via ref
Detect key releasekeyupWindow/ElementFires when key is released
Key press for typingkeypressElement⚠️ Deprecated, use keydown instead
Text input changesinputElementFires on every character change
Form field value committedchangeElementFires when user leaves field

MDN Reference: Keyboard events

Mouse & Pointer

Use CaseEventTargetNotes
Click on elementclickElementStandard click handler
Right-click menucontextmenuElementPrevent default to show custom menu
Double clickdblclickElementTwo rapid clicks
Mouse button pressedmousedownElementFires immediately on press
Mouse button releasedmouseupElementFires on release
Hover entermouseenterElementDoesn't bubble (use for single element)
Hover leavemouseleaveElementDoesn't bubble
Mouse moved overmouseoverElementBubbles (fires for children too)
Track mouse positionmousemoveElement/WindowUse passive for performance
Mouse wheel scrollwheelElementFor custom scroll behavior

MDN Reference: Mouse events

Touch (Mobile)

Use CaseEventTargetNotes
Touch startedtouchstartElementUse { passive: true } for scroll perf
Finger movingtouchmoveElementUse passive unless preventing scroll
Touch endedtouchendElementFinger lifted
Touch cancelledtouchcancelElementTouch interrupted by system

MDN Reference: Touch events

Focus & Forms

Use CaseEventTargetNotes
Element focusedfocusElementInput received focus
Element blurredblurElementInput lost focus
Focus within containerfocusinElementBubbles (unlike focus)
Blur within containerfocusoutElementBubbles (unlike blur)
Form submittedsubmitElementAttach to <form> ref
Form resetresetElementForm cleared
Input value changedinputElementReal-time updates
Value committedchangeElementAfter blur or Enter

MDN Reference: Focus events

Window & Viewport

Use CaseEventTargetNotes
Browser resizedresizeWindowDebounce for performance
Page scrolledscrollWindowUse { passive: true }
Before page unloadbeforeunloadWindowWarn before leaving
Page fully loadedloadWindowAll resources loaded
DOM readyDOMContentLoadedDocumentHTML parsed, before images
Print startedbeforeprintWindowHide elements before print
Print endedafterprintWindowRestore elements after print

MDN Reference: Window events

Page Lifecycle & Visibility

Use CaseEventTargetNotes
Tab visibility changedvisibilitychangeDocumentPause when tab hidden
Page shownpageshowWindowIncludes back/forward cache
Page hiddenpagehideWindowBefore unload
Fullscreen changedfullscreenchangeDocumentEnter/exit fullscreen
Fullscreen errorfullscreenerrorDocumentFullscreen request failed

MDN Reference: Page Visibility API

Network & Connectivity

Use CaseEventTargetNotes
Went onlineonlineWindowNetwork restored
Went offlineofflineWindowNetwork lost

MDN Reference: Navigator.onLine

Storage & State

Use CaseEventTargetNotes
localStorage changedstorageWindowOnly fires in other tabs
Hash URL changedhashchangeWindowURL fragment changed
History navigationpopstateWindowBack/forward button

MDN Reference: Storage event

Media & Animation

Use CaseEventTargetNotes
Video/audio endedendedElementMedia playback finished
Media pausedpauseElementPlayback paused
Media playingplayElementPlayback started
Time updatedtimeupdateElementPlayback position changed
Animation endedanimationendElementCSS animation completed
Transition endedtransitionendElementCSS transition completed

MDN Reference: Media events

Clipboard

Use CaseEventTargetNotes
Content copiedcopyElement/DocumentUser copied content
Content cutcutElement/DocumentUser cut content
Content pastedpasteElement/DocumentUser pasted content

MDN Reference: Clipboard events

Drag & Drop

Use CaseEventTargetNotes
Drag starteddragstartElementElement being dragged
DraggingdragElementDuring drag
Drag endeddragendElementDrag finished
Dragged overdragoverElementSomething dragged over target
Entered drop zonedragenterElementEntered valid drop target
Left drop zonedragleaveElementLeft valid drop target
DroppeddropElementItem dropped

MDN Reference: Drag and Drop API


Further Reading


Hook Source Code

"use client";
import { RefObject, useEffect, useRef } from "react";

/**
 * Check if code is running in a browser environment.
 * This ensures SSR compatibility.
 */
const isBrowser = typeof window !== "undefined";

/**
 * Extended options for useEventListener
 */
export interface EventListenerOptions extends AddEventListenerOptions {
    /** Prevent default browser behavior when event fires */
    preventDefault?: boolean;
    /** Stop event propagation when event fires */
    stopPropagation?: boolean;
}

/**
 * Attaches an event listener to the window.
 *
 * @param eventName - The event name (e.g., 'resize', 'scroll', 'keydown').
 * @param handler - Callback function invoked when the event fires.
 * @param element - Leave undefined to attach to window.
 * @param options - Extended addEventListener options with preventDefault/stopPropagation.
 */
export function useEventListener<K extends keyof WindowEventMap>(
    eventName: K,
    handler: (event: WindowEventMap[K]) => void,
    element?: null,
    options?: boolean | EventListenerOptions,
): void;

/**
 * Attaches an event listener to the document.
 *
 * @param eventName - The event name (e.g., 'visibilitychange', 'fullscreenchange').
 * @param handler - Callback function invoked when the event fires.
 * @param element - Pass "document" string to attach to document.
 * @param options - Extended addEventListener options with preventDefault/stopPropagation.
 */
export function useEventListener<K extends keyof DocumentEventMap>(
    eventName: K,
    handler: (event: DocumentEventMap[K]) => void,
    element: "document",
    options?: boolean | EventListenerOptions,
): void;

/**
 * Attaches an event listener to an HTML element via ref.
 *
 * @param eventName - The event name (e.g., 'click', 'mouseenter', 'focus').
 * @param handler - Callback function invoked when the event fires.
 * @param element - A React ref pointing to the target element.
 * @param options - Extended addEventListener options with preventDefault/stopPropagation.
 */
export function useEventListener<
    K extends keyof HTMLElementEventMap,
    T extends HTMLElement,
>(
    eventName: K,
    handler: (event: HTMLElementEventMap[K]) => void,
    element: RefObject<T | null>,
    options?: boolean | EventListenerOptions,
): void;

/**
 * Implementation
 */
export function useEventListener(
    eventName: string,
    handler: (event: Event) => void,
    element?: RefObject<HTMLElement | null> | "document" | null,
    options?: boolean | EventListenerOptions,
): void {
    // Store handler in ref to avoid effect re-runs when handler changes
    const handlerRef = useRef(handler);

    useEffect(() => {
        handlerRef.current = handler;
    }, [handler]);

    useEffect(() => {
        // SSR guard
        if (!isBrowser) return;

        // Resolve the target
        const target =
            element === "document" ? document : (element?.current ?? window);

        // Guard against null refs
        if (!target) return;

        // Extract custom options
        const extendedOptions = typeof options === "object" ? options : {};
        const { preventDefault, stopPropagation, ...nativeOptions } =
            extendedOptions;

        // Wrapper that calls the latest handler with optional prevent/stop
        const listener = (event: Event) => {
            if (preventDefault) event.preventDefault();
            if (stopPropagation) event.stopPropagation();
            handlerRef.current(event);
        };

        const listenerOptions =
            typeof options === "boolean" ? options : nativeOptions;

        target.addEventListener(eventName, listener, listenerOptions);

        return () => {
            target.removeEventListener(eventName, listener, listenerOptions);
        };
    }, [eventName, element, options]);
}