An event listener is a function the browser calls when something happens to an element, such as a click, a form submit or a scroll. You attach one with element.addEventListener(type, handler, options). The options object turns on once, passive, capture or an abort signal, and removeEventListener detaches the same function again later.
| Option | Default | Effect |
|---|---|---|
| once | false | Removes the listener automatically after its first call |
| passive | false (true for wheel and touch on window, document and body) | Promises not to call preventDefault, so scrolling never waits |
| capture | false | Runs on the way down to the target instead of on the way back up |
| signal | none | An AbortSignal; aborting it removes the listener |
Updated
Events are easier to understand when you can see them. The event log below records every listener call on a small test area, so you can click, type, submit and scroll and watch the order, the target and the effect of each option. Then copy the patterns for the jobs you actually have.
Click, type, submit and scroll in the test area. Every listener call is written to the log with its type, target and current target.
| Event | Fires when | Bubbles? |
|---|---|---|
| click | A pointer press and release, or Enter or Space on a button | Yes |
| input | The value of a field changes as the visitor types | Yes |
| change | A field's value is committed, for example on blur or selection | Yes |
| submit | A form is submitted; fires on the form element | Yes |
| keydown | A key is pressed | Yes |
| focus / blur | An element gains or loses focus | No |
| focusin / focusout | Same as focus and blur, but bubbling | Yes |
| DOMContentLoaded | The HTML is parsed and deferred scripts have run; listen on document | Not relevant |
Keeping the function in a variable is what makes removeEventListener work.
const button = document.querySelector("#menu-button");
function toggleMenu() {
const open = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!open));
}
button.addEventListener("click", toggleMenu);
// later
button.removeEventListener("click", toggleMenu);preventDefault stops the browser's own navigation. event.submitter tells you which submit button was used.
const form = document.querySelector("#signup");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
const status = form.querySelector("[role=status]");
status.textContent = "Sending...";
try {
const res = await fetch(form.action, { method: "POST", body: data });
status.textContent = res.ok ? "Thanks, you are on the list." : "Something went wrong.";
} catch {
status.textContent = "Network error, please try again.";
}
});One listener on the list handles every delete button, including rows added after the page loaded.
const list = document.querySelector("#todo-list");
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action=delete]");
if (!button || !list.contains(button)) return;
button.closest("li").remove();
});passive: true lets scrolling start right away, and requestAnimationFrame keeps the work to once per frame.
const header = document.querySelector("header");
let queued = false;
window.addEventListener("scroll", () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
header.classList.toggle("is-scrolled", window.scrollY > 40);
queued = false;
});
}, { passive: true });The first listener runs a single time. The others are all removed together by one abort() call.
document.querySelector("#cookie-ok")
.addEventListener("click", () => banner.remove(), { once: true });
const controller = new AbortController();
const { signal } = controller;
window.addEventListener("resize", onResize, { signal });
document.addEventListener("keydown", onKey, { signal });
// when this feature is torn down:
controller.abort();Checking the target stops the shortcut from firing while someone is typing a slash into a search box.
document.addEventListener("keydown", (event) => {
const typing = event.target.closest("input, textarea, select, [contenteditable]");
if (event.key === "/" && !typing) {
event.preventDefault();
document.querySelector("#search").focus();
}
});
A dispatched event moves in three phases. It travels down from the window to the target in the capture phase, reaches the target, then bubbles back up through each ancestor. Listeners run in the bubble phase unless you ask for capture.
stopPropagation halts the journey, so ancestors never hear it. Use it rarely, since delegated listeners and analytics further up the tree silently stop working.
Scroll events can fire many times per frame of movement. Keep the handler tiny, read positions instead of writing layout, and batch visual updates inside requestAnimationFrame.
For jobs like revealing elements as they enter view, an IntersectionObserver is usually cheaper than measuring on every scroll. The scrollend event, Baseline since December 2025 according to MDN, fires once when movement stops.
Listeners on long-lived objects such as window or document keep their handler, and anything it references, alive. When the part of the page that needed them goes away, remove them.
The signal option makes this tidy. Create one AbortController per feature, pass its signal to every listener, and call abort() once during teardown.
Instead of attaching a listener to every item, you attach one to a shared parent. Because most events bubble up, the parent hears clicks from all its children, including items added later, and uses event.target to tell which one was clicked.
It must receive the same function reference and the same capture setting that addEventListener was given. Two arrow functions with identical code are still different functions, so define the handler once and pass that variable both times.
It tells the browser your handler will not call preventDefault, so the page can start scrolling immediately rather than waiting for your code to finish. Browsers already default to passive for wheel and touch listeners on window, document and body.
target is the innermost element where the event started, and it stays the same as the event bubbles. currentTarget is the element whose listener is running right now, which changes at each level.
No. Calling form.submit() from a script skips the submit event and skips validation. Call form.requestSubmit() when you want your submit listeners and the built-in checks to run.
The onclick property holds one function, so assigning a second one replaces the first. addEventListener lets several independent listeners share an element and supports the options object, so it is the better default.
Related: JavaScript accordion and tabs, JavaScript fetch and async, How to add a contact form, localStorage in JavaScript, Keyboard and screen reader testing.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.