Skip to main content

Events & Interactivity

Events are how JavaScript responds to user actions - clicks, key presses, form submissions, scrolling, and more. This chapter covers everything you need to make interactive web pages.

Adding event listeners​

The standard way to respond to events is addEventListener:

<button id="greet-btn">Say Hello</button>
<p id="output"></p>
const button = document.querySelector("#greet-btn");
const output = document.querySelector("#output");

button.addEventListener("click", function () {
output.textContent = "Hello, World!";
});

When the button is clicked, the paragraph shows "Hello, World!".

With an arrow function​

button.addEventListener("click", () => {
output.textContent = "Hello from an arrow function!";
});

With a named function​

function handleClick() {
output.textContent = "Hello from a named function!";
}

button.addEventListener("click", handleClick);

Named functions are useful when you need to remove the listener later.

Removing event listeners​

function handleClick() {
console.log("Clicked!");
}

button.addEventListener("click", handleClick);

// Later, remove it
button.removeEventListener("click", handleClick);

Important: you must pass the same function reference. If you used capture mode, the capture setting must match too:

button.addEventListener("click", handleClick, { capture: true });
button.removeEventListener("click", handleClick, { capture: true });

Anonymous functions cannot be removed:

// This does NOT work - each arrow function is a different reference
button.addEventListener("click", () => console.log("A"));
button.removeEventListener("click", () => console.log("A")); // does nothing

One-time listeners​

Use the once option to automatically remove after the first trigger:

button.addEventListener("click", () => {
console.log("This only fires once");
}, { once: true });

The event object​

Every event handler receives an event object with details about what happened:

button.addEventListener("click", (event) => {
console.log("Type:", event.type);
console.log("Target:", event.target.tagName);
console.log("X:", event.clientX, "Y:", event.clientY);
console.log("Timestamp:", event.timeStamp);
});

Result (when clicking):

Type: click
Target: BUTTON
X: 150 Y: 200
Timestamp: 12345.67

Common properties:

PropertyDescription
event.typeEvent name ("click", "keydown", etc.)
event.targetThe element that triggered the event
event.currentTargetThe element the listener is attached to
event.clientX / clientYMouse position relative to viewport
event.keyKey pressed (for keyboard events)
event.timeStampWhen the event occurred

Common event types​

Mouse events​

const box = document.querySelector(".box");

box.addEventListener("click", () => console.log("Clicked"));
box.addEventListener("dblclick", () => console.log("Double-clicked"));
box.addEventListener("mouseenter", () => console.log("Mouse entered"));
box.addEventListener("mouseleave", () => console.log("Mouse left"));
box.addEventListener("mousemove", (e) => {
console.log(`Mouse at (${e.clientX}, ${e.clientY})`);
});

Keyboard events​

document.addEventListener("keydown", (event) => {
console.log(`Key down: ${event.key}`);
});

document.addEventListener("keyup", (event) => {
console.log(`Key up: ${event.key}`);
});

Common event.key values: "Enter", "Escape", "ArrowUp", "ArrowDown", "a", "A", " " (space).

document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
console.log("Escape pressed - close the modal");
}

// Check for modifier keys
if (event.ctrlKey && event.key === "s") {
event.preventDefault(); // prevent browser save dialog
console.log("Ctrl+S pressed - saving...");
}
});

Focus events​

const input = document.querySelector("input");

input.addEventListener("focus", () => {
console.log("Input focused");
});

input.addEventListener("blur", () => {
console.log("Input lost focus");
});

Input events​

const searchInput = document.querySelector("#search");

// Fires on every keystroke
searchInput.addEventListener("input", (event) => {
console.log("Current value:", event.target.value);
});

// Fires when the field loses focus after being changed
searchInput.addEventListener("change", (event) => {
console.log("Changed to:", event.target.value);
});

Scroll events​

window.addEventListener("scroll", () => {
console.log(`Scrolled to: ${window.scrollY}px`);
});

Performance tip: scroll events fire very rapidly. Use throttling or requestAnimationFrame for heavy handlers. If a scroll or touch handler never calls preventDefault(), use { passive: true } so the browser can keep scrolling smoothly. Do not use passive listeners when you need to cancel the event.

Page load events​

// DOM is ready (HTML parsed, but images/CSS may still be loading)
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM is ready");
});

// Everything is fully loaded (images, CSS, etc.)
window.addEventListener("load", () => {
console.log("Page fully loaded");
});

Best practice: place your <script> tag at the end of <body>, or use DOMContentLoaded to ensure the DOM is available before your code runs.

Preventing default behavior​

Some events have built-in browser behavior. Use preventDefault() to stop it:

// Prevent a link from navigating
const link = document.querySelector("a");
link.addEventListener("click", (event) => {
event.preventDefault();
console.log("Link click prevented");
});

// Prevent form submission (handle it with JavaScript instead)
const form = document.querySelector("form");
form.addEventListener("submit", (event) => {
event.preventDefault();
console.log("Form submission prevented");
});

Event bubbling and capturing​

When you click a button inside a <div>, the event fires on the button and on every ancestor up to document. This is called bubbling.

Interactive demo​

Click the nested button and watch the bubble path. Toggle stopPropagation to cut it short:

Event bubbling lab

Click the button. Watch the event travel from the target up through parent listeners.

outer
middle
  1. Click the button to see the bubble path.
<div id="outer">
<div id="inner">
<button id="btn">Click me</button>
</div>
</div>
document.querySelector("#outer").addEventListener("click", () => {
console.log("Outer div clicked");
});

document.querySelector("#inner").addEventListener("click", () => {
console.log("Inner div clicked");
});

document.querySelector("#btn").addEventListener("click", () => {
console.log("Button clicked");
});

Result (clicking the button):

Button clicked
Inner div clicked
Outer div clicked

The event bubbles from the target (button) up through its ancestors.

Stopping propagation​

Prevent the event from reaching parent elements:

document.querySelector("#btn").addEventListener("click", (event) => {
event.stopPropagation();
console.log("Button clicked - stopped bubbling");
});

// This will NOT fire when the button is clicked
document.querySelector("#outer").addEventListener("click", () => {
console.log("Outer div clicked");
});

Result (clicking the button):

Button clicked - stopped bubbling

Use stopPropagation sparingly - it can break other listeners that rely on bubbling.

Capture phase​

Events actually go through three phases:

  1. Capture - from document down to the target
  2. Target - the element that was clicked
  3. Bubble - back up from the target to document

To listen during the capture phase:

document.querySelector("#outer").addEventListener("click", () => {
console.log("Outer (capture phase)");
}, true); // third argument = use capture

document.querySelector("#btn").addEventListener("click", () => {
console.log("Button (target)");
});

document.querySelector("#outer").addEventListener("click", () => {
console.log("Outer (bubble phase)");
});

Result (clicking the button):

Outer (capture phase)
Button (target)
Outer (bubble phase)

You will rarely need capture phase listeners, but understanding the flow helps with debugging.

Event delegation​

Instead of attaching listeners to every child element, attach one listener to a parent and use event.target to determine which child was clicked:

<ul id="task-list">
<li>Task 1</li>
<li>Task 2</li>
<li>Task 3</li>
</ul>
// Without delegation - one listener per item (bad for many items)
document.querySelectorAll("#task-list li").forEach((li) => {
li.addEventListener("click", () => {
console.log(`Clicked: ${li.textContent}`);
});
});

// With delegation - one listener on the parent (good!)
document.querySelector("#task-list").addEventListener("click", (event) => {
if (event.target.tagName === "LI") {
console.log(`Clicked: ${event.target.textContent}`);
}
});

Benefits of delegation:

  • Performance: one listener instead of many
  • Dynamic elements: works for elements added later (they bubble to the parent)
  • Less memory: fewer event listener objects

Delegation with closest​

For elements with nested children, event.target might be a child of the element you care about:

<ul id="user-list">
<li data-id="1"><strong>Ada</strong> <span>Admin</span></li>
<li data-id="2"><strong>Bob</strong> <span>User</span></li>
</ul>
document.querySelector("#user-list").addEventListener("click", (event) => {
const li = event.target.closest("li");
if (li) {
console.log(`Clicked user ID: ${li.dataset.id}`);
}
});

closest walks up the tree from event.target to find the nearest matching ancestor. It handles clicks on <strong> or <span> inside the <li>.

Form handling​

Forms are one of the most common uses of events:

<form id="signup">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required minlength="3">

<label for="email">Email:</label>
<input type="email" id="email" name="email" required>

<button type="submit">Sign Up</button>
</form>
<div id="result"></div>
const form = document.querySelector("#signup");
const result = document.querySelector("#result");

form.addEventListener("submit", (event) => {
event.preventDefault(); // prevent page reload

// Access form data
const formData = new FormData(form);
const username = formData.get("username");
const email = formData.get("email");

// Or access directly
const usernameAlt = form.elements.username.value;

result.textContent = `Welcome, ${username} (${email})!`;

// Clear the form
form.reset();
});

Real-time validation​

const usernameInput = document.querySelector("#username");
const feedback = document.createElement("span");
usernameInput.parentElement.appendChild(feedback);

usernameInput.addEventListener("input", (event) => {
const value = event.target.value;

if (value.length === 0) {
feedback.textContent = "";
} else if (value.length < 3) {
feedback.textContent = "Too short (min 3 characters)";
feedback.style.color = "red";
} else if (value.length > 20) {
feedback.textContent = "Too long (max 20 characters)";
feedback.style.color = "red";
} else {
feedback.textContent = "Looks good!";
feedback.style.color = "green";
}
});

For more on sanitizing user input, see User Input Sanitization.

Checkboxes and radio buttons​

// Checkbox
const checkbox = document.querySelector('input[type="checkbox"]');
checkbox.addEventListener("change", () => {
console.log("Checked:", checkbox.checked);
});

// Radio buttons
document.querySelectorAll('input[name="theme"]').forEach((radio) => {
radio.addEventListener("change", (event) => {
console.log("Selected theme:", event.target.value);
});
});

Select dropdown​

const select = document.querySelector("select");
select.addEventListener("change", (event) => {
console.log("Selected:", event.target.value);
});

Practical examples​

Toggle visibility​

<button id="toggle-btn">Toggle Content</button>
<div id="content" class="visible">
<p>This content can be shown or hidden.</p>
</div>
.hidden { display: none; }
.visible { display: block; }
const toggleBtn = document.querySelector("#toggle-btn");
const content = document.querySelector("#content");

toggleBtn.addEventListener("click", () => {
content.classList.toggle("hidden");
content.classList.toggle("visible");

toggleBtn.textContent = content.classList.contains("hidden")
? "Show Content"
: "Toggle Content";
});

Theme toggle​

const themeBtn = document.querySelector("#theme-toggle");

themeBtn.addEventListener("click", () => {
document.body.classList.toggle("dark-theme");

const isDark = document.body.classList.contains("dark-theme");
themeBtn.textContent = isDark ? "Light Mode" : "Dark Mode";

// Save preference
localStorage.setItem("theme", isDark ? "dark" : "light");
});

// Restore on page load
document.addEventListener("DOMContentLoaded", () => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme === "dark") {
document.body.classList.add("dark-theme");
themeBtn.textContent = "Light Mode";
}
});

Dynamic list with add and delete​

<div id="todo-app">
<input type="text" id="todo-input" placeholder="Add a task...">
<button id="add-btn">Add</button>
<ul id="todo-list"></ul>
</div>
const input = document.querySelector("#todo-input");
const addBtn = document.querySelector("#add-btn");
const list = document.querySelector("#todo-list");

function addTask() {
const text = input.value.trim();
if (text === "") return;

const li = document.createElement("li");
li.textContent = text;

const deleteBtn = document.createElement("button");
deleteBtn.textContent = "Delete";
deleteBtn.style.marginLeft = "8px";
li.appendChild(deleteBtn);

list.appendChild(li);
input.value = "";
input.focus();
}

// Add on button click
addBtn.addEventListener("click", addTask);

// Add on Enter key
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
addTask();
}
});

// Delete using event delegation
list.addEventListener("click", (event) => {
if (event.target.tagName === "BUTTON") {
event.target.parentElement.remove();
}
});

Prevent firing on every keystroke - wait until the user stops typing:

function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}

const searchInput = document.querySelector("#search");
const results = document.querySelector("#results");

const handleSearch = debounce((query) => {
console.log(`Searching for: ${query}`);
results.textContent = `Results for "${query}"...`;
}, 300);

searchInput.addEventListener("input", (event) => {
handleSearch(event.target.value);
});

The search function fires 300ms after the user stops typing, instead of on every keystroke.

Custom events​

Create and dispatch your own events:

// Create a custom event
const userLoggedIn = new CustomEvent("userLogin", {
detail: { username: "Ada", role: "admin" },
});

// Listen for it
document.addEventListener("userLogin", (event) => {
console.log(`${event.detail.username} logged in as ${event.detail.role}`);
});

// Dispatch it
document.dispatchEvent(userLoggedIn);

Result:

Ada logged in as admin

Custom events are useful for decoupling components - one part of your code can emit events without knowing who is listening.

Summary​

  • addEventListener is the standard way to handle events; removeEventListener requires the same function reference.
  • The event object provides details: type, target, key, coordinates, etc.
  • preventDefault() stops default browser behavior (link navigation, form submission).
  • Events bubble from child to parent. Use stopPropagation() sparingly.
  • Event delegation attaches one listener to a parent - better performance, works for dynamic elements.
  • closest() helps find the relevant element in delegated handlers.
  • Forms: use FormData or form.elements to access values, event.preventDefault() to handle submission in JavaScript.
  • Debounce expensive handlers like search to avoid firing on every keystroke.

Next up: Working with Data - fetching data from APIs and storing data in the browser.