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.jsonFeatures
- 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, andonce.
Source Code
View the full hook implementation in the Hook Source Code section below.
Learn More
MDN: addEventListener()
The underlying browser API for attaching event listeners
MDN: Event reference
Complete list of all DOM events available in browsers
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)
| Target | Element Parameter | TypeScript Interface | Common Events |
|---|---|---|---|
| Window | undefined or null | WindowEventMap | resize, scroll, keydown, online, offline, storage |
| Document | "document" (string) | DocumentEventMap | visibilitychange, fullscreenchange, selectionchange |
| Element | RefObject<HTMLElement> | HTMLElementEventMap | click, 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 1Listening 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 1Listening to keydown on window. Press any key or combination.
"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 1Listening to scroll on window with passive: true for performance.
"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 1Listening to online and offline on window. Toggle your network to test.
"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 1Listening 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'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 definederrors) - 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 2Listening to visibilitychange on document. Switch tabs to trigger it.
"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 3Listening 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 3Listening to focus and blur on an input element via ref.
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 3Three listeners on one element: mousemove, mouseenter, and mouseleave.
(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 3Listening to touchstart, touchmove, and touchend with passive: true.
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
| Parameter | Type | Description |
|---|---|---|
eventName | string | The event name (e.g., 'click', 'resize', 'keydown'). |
handler | (event) => void | Callback invoked when the event fires. Event type is inferred automatically. |
element | RefObject | "document" | null | Target for the listener. Defaults to window if null or undefined. |
options | boolean | AddEventListenerOptions | Standard event listener options: capture, passive, once. |
Options Explained
| Option | Type | Description |
|---|---|---|
passive | boolean | If true, handler won't call preventDefault(). Improves scroll/touch performance. |
capture | boolean | If true, handler runs during capture phase (parent → child) instead of bubble phase. |
once | boolean | If 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:
| Interface | Target | Documentation |
|---|---|---|
WindowEventMap | window | MDN: Window events |
DocumentEventMap | document | MDN: Document events |
HTMLElementEventMap | HTML elements | MDN: 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 Case | Event | Target | Notes |
|---|---|---|---|
| Global keyboard shortcuts | keydown | Window | Use for app-wide shortcuts like Cmd+S |
| Keyboard input in field | keydown / keyup | Element | Attach to specific input via ref |
| Detect key release | keyup | Window/Element | Fires when key is released |
| Key press for typing | keypress | Element | ⚠️ Deprecated, use keydown instead |
| Text input changes | input | Element | Fires on every character change |
| Form field value committed | change | Element | Fires when user leaves field |
MDN Reference: Keyboard events
Mouse & Pointer
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Click on element | click | Element | Standard click handler |
| Right-click menu | contextmenu | Element | Prevent default to show custom menu |
| Double click | dblclick | Element | Two rapid clicks |
| Mouse button pressed | mousedown | Element | Fires immediately on press |
| Mouse button released | mouseup | Element | Fires on release |
| Hover enter | mouseenter | Element | Doesn't bubble (use for single element) |
| Hover leave | mouseleave | Element | Doesn't bubble |
| Mouse moved over | mouseover | Element | Bubbles (fires for children too) |
| Track mouse position | mousemove | Element/Window | Use passive for performance |
| Mouse wheel scroll | wheel | Element | For custom scroll behavior |
MDN Reference: Mouse events
Touch (Mobile)
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Touch started | touchstart | Element | Use { passive: true } for scroll perf |
| Finger moving | touchmove | Element | Use passive unless preventing scroll |
| Touch ended | touchend | Element | Finger lifted |
| Touch cancelled | touchcancel | Element | Touch interrupted by system |
MDN Reference: Touch events
Focus & Forms
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Element focused | focus | Element | Input received focus |
| Element blurred | blur | Element | Input lost focus |
| Focus within container | focusin | Element | Bubbles (unlike focus) |
| Blur within container | focusout | Element | Bubbles (unlike blur) |
| Form submitted | submit | Element | Attach to <form> ref |
| Form reset | reset | Element | Form cleared |
| Input value changed | input | Element | Real-time updates |
| Value committed | change | Element | After blur or Enter |
MDN Reference: Focus events
Window & Viewport
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Browser resized | resize | Window | Debounce for performance |
| Page scrolled | scroll | Window | Use { passive: true } |
| Before page unload | beforeunload | Window | Warn before leaving |
| Page fully loaded | load | Window | All resources loaded |
| DOM ready | DOMContentLoaded | Document | HTML parsed, before images |
| Print started | beforeprint | Window | Hide elements before print |
| Print ended | afterprint | Window | Restore elements after print |
MDN Reference: Window events
Page Lifecycle & Visibility
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Tab visibility changed | visibilitychange | Document | Pause when tab hidden |
| Page shown | pageshow | Window | Includes back/forward cache |
| Page hidden | pagehide | Window | Before unload |
| Fullscreen changed | fullscreenchange | Document | Enter/exit fullscreen |
| Fullscreen error | fullscreenerror | Document | Fullscreen request failed |
MDN Reference: Page Visibility API
Network & Connectivity
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Went online | online | Window | Network restored |
| Went offline | offline | Window | Network lost |
MDN Reference: Navigator.onLine
Storage & State
| Use Case | Event | Target | Notes |
|---|---|---|---|
| localStorage changed | storage | Window | Only fires in other tabs |
| Hash URL changed | hashchange | Window | URL fragment changed |
| History navigation | popstate | Window | Back/forward button |
MDN Reference: Storage event
Media & Animation
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Video/audio ended | ended | Element | Media playback finished |
| Media paused | pause | Element | Playback paused |
| Media playing | play | Element | Playback started |
| Time updated | timeupdate | Element | Playback position changed |
| Animation ended | animationend | Element | CSS animation completed |
| Transition ended | transitionend | Element | CSS transition completed |
MDN Reference: Media events
Clipboard
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Content copied | copy | Element/Document | User copied content |
| Content cut | cut | Element/Document | User cut content |
| Content pasted | paste | Element/Document | User pasted content |
MDN Reference: Clipboard events
Drag & Drop
| Use Case | Event | Target | Notes |
|---|---|---|---|
| Drag started | dragstart | Element | Element being dragged |
| Dragging | drag | Element | During drag |
| Drag ended | dragend | Element | Drag finished |
| Dragged over | dragover | Element | Something dragged over target |
| Entered drop zone | dragenter | Element | Entered valid drop target |
| Left drop zone | dragleave | Element | Left valid drop target |
| Dropped | drop | Element | Item dropped |
MDN Reference: Drag and Drop API
Further Reading
- MDN: addEventListener() - The underlying browser API
- MDN: Event reference - Complete list of all DOM events
- web.dev: Passive event listeners - Performance optimization guide
- MDN: Page Visibility API - Detecting tab visibility
- MDN: Touch events - Mobile touch handling guide
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]);
}