Chapter 2
Events
How event listeners connect what people do to how the interface responds, from the event object and bubbling to delegation and choosing the meaningful event, and an account settings page built from small behaviors.
Someone opens their account settings to change the email address on file. They type the new address, tab to the next field, and expect to hear right away if they’ve made a typo. They want to check the new password they’re typing, so they press a button to show it. When they’re done, the Save button should be ready, and when they haven’t changed anything, it shouldn’t pretend there’s something to save.
Every one of those responses starts with an event: the browser noticing something the person did, such as a click, a keystroke, focus leaving a field, or a form being submitted, and telling your code about it. JavaScript is the layer that connects what someone intended to how the interface responds.
The mental model
This chapter answers the second of the four questions from the introduction: what events can change the state? Every interaction in this book follows the same loop:
- The browser notices something and fires an event.
- Your listener runs and decides what the event means.
- The listener updates what the interface remembers.
- The page updates to match.
Most of the skill is in step 2. A single key press can fire keydown, input, keyup, and sometimes submit, all for one action. Your job is to pick the event that matches what the person meant.
Listening for an event
The pattern is a disclosure button, which you built in chapter 1. Here is its listener again:
const toggle = document.querySelector('[data-details-toggle]');
toggle.addEventListener('click', () => {
console.log('The button was activated');
});
addEventListener takes two arguments: the name of the event, and a function to run when it happens. That function is the event handler. The browser calls it for you every time the event fires, and it can fire as many times as the person clicks.
You can also define the handler first and pass it by name:
function handleToggle() {
console.log('The button was activated');
}
toggle.addEventListener('click', handleToggle);
Pass the function itself, handleToggle, without parentheses. Writing handleToggle() calls the function once while the listener is being set up and passes its return value, undefined, to addEventListener. The click then does nothing, and the log message appears once when the page loads.
What the event object tells you
The browser passes an event object to your handler as its first argument. It describes what happened:
toggle.addEventListener('click', (event) => {
console.log(event.type); // "click"
console.log(event.target); // the element the click happened on
console.log(event.currentTarget); // the element the listener is attached to
});
Different events carry different details. A keyboard event has event.key, a pointer event has coordinates, and a submit event has event.submitter, the button that submitted the form.
target and currentTarget
These two properties look similar and cause a lot of confusion. event.target is the element the event happened on. event.currentTarget is the element whose listener is running.
Consider a password visibility toggle with an icon inside it:
<button type="button" data-password-toggle>
<svg aria-hidden="true" class="icon"><!-- eye icon --></svg>
Show password
</button>
When someone clicks directly on the icon, event.target is the <svg> (or a shape inside it), because that is the innermost element under the pointer. event.currentTarget is still the button, because that’s where you attached the listener. If your handler reads event.target.dataset, it works when people click the text and fails when they click the icon. When you mean “the element I attached this to,” use event.currentTarget, or the variable you already have.
Choosing the right event
The browser fires many events, and several can describe the same action. This table covers the ones you’ll use most.
| Event | Fires when | Use it for |
|---|---|---|
click | A button or link is activated by a pointer, or by Enter or Space on a button | Buttons that do something |
input | A field’s value changes, on every keystroke or paste | Live previews, character counters, re-checking a field that has an error |
change | The person commits a value: immediately for checkboxes, radios, and selects, and when a text field loses focus after being edited | Checkboxes, radios, selects, checking a field after someone leaves it |
submit | A form is submitted by its submit button, by Enter in a field, or by form.requestSubmit() | Saving or sending a form |
keydown | A key is pressed | Escape to dismiss, arrow keys inside composite widgets |
focus / blur | An element gains or loses focus. These don’t bubble. | Reacting on one specific element |
focusin / focusout | The same moments, but these bubble | Reacting to focus anywhere inside a container |
Character counters and the input event
A character counter needs to change as the person types, so it listens for input:
<label for="bio">Short bio</label>
<textarea id="bio" maxlength="160" data-bio aria-describedby="bio-count"></textarea>
<p id="bio-count" data-bio-count>160 characters left</p>
const bio = document.querySelector('[data-bio]');
const bioCount = document.querySelector('[data-bio-count]');
bio.addEventListener('input', () => {
const remaining = bio.maxLength - bio.value.length;
bioCount.textContent = `${remaining} characters left`;
});
bio.maxLength reads the maxlength attribute as a number, so the limit lives in one place in the HTML.
Submit instead of click
When a form saves something, listen for submit on the form, not click on the button. The submit event describes what the person meant, which is “send this form,” and it covers every way of getting there: clicking the button, pressing Enter in a text field, or a script calling form.requestSubmit(). When the browser’s built-in validation is on and a required field is empty, the browser blocks submission and submit never fires. A click listener on the button would still run in that case.
const form = document.querySelector('[data-settings-form]');
form.addEventListener('submit', (event) => {
event.preventDefault();
console.log('Save the settings here');
});
Keyboard events
Real buttons already respond to Enter and Space by firing click, so you don’t add keyboard listeners to them. Use keydown for keys that have no built-in behavior in that context, like Escape to close the shipping details from chapter 1:
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && isOpen) {
isOpen = false;
details.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
toggle.focus();
}
});
event.key is a readable string: 'Escape', 'Enter', 'ArrowDown', and ' ' (a single space) for the space bar. Chapter 9 covers arrow keys in tabs and menus.
Focus and blur
focus fires when an element receives focus, and blur fires when it loses it. Both are useful for form validation, because leaving a field is a natural point to check it. They don’t bubble, though, so a listener on a form never hears them from its fields. Their bubbling versions, focusin and focusout, do reach the form, which matters for the next section.
Bubbling and event delegation
When an event fires on an element, it doesn’t stop there. After the listeners on the target run, the event bubbles up through each ancestor in turn: the parent, the grandparent, and so on up to document. A listener on any of those ancestors hears it.
That makes event delegation possible. Instead of attaching a listener to every item in a group, you attach one to the container and check where the event came from. The pattern is a selectable card group, here built from radio buttons so the browser handles keyboard selection:
<fieldset class="plans" data-plan-group>
<legend>Choose a plan</legend>
<label class="plan" data-plan-card>
<input type="radio" name="plan" value="Starter"> Starter
</label>
<label class="plan" data-plan-card>
<input type="radio" name="plan" value="Team"> Team
</label>
<label class="plan" data-plan-card>
<input type="radio" name="plan" value="Enterprise"> Enterprise
</label>
</fieldset>
<p data-plan-summary>No plan selected.</p>
const planGroup = document.querySelector('[data-plan-group]');
const planSummary = document.querySelector('[data-plan-summary]');
planGroup.addEventListener('change', (event) => {
const radio = event.target;
for (const card of planGroup.querySelectorAll('[data-plan-card]')) {
card.classList.toggle('is-selected', card.contains(radio));
}
planSummary.textContent = `You chose the ${radio.value} plan.`;
});
The change event fires on the radio, so event.target is the radio, and it bubbles to the fieldset, which is event.currentTarget. One listener handles all three cards, and it would still work if a script added a fourth card later, since the listener is on the container. (In current browsers you could style the selected card in CSS with .plan:has(:checked), which leaves JavaScript only the summary to update.)
When the items in a container hold other elements, use event.target.closest() to find the item the event belongs to. closest() checks the element and then each ancestor, returning the first one that matches a selector, or null:
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-dismiss]');
if (!button) return;
// handle the dismiss button
});
You can stop an event from bubbling with event.stopPropagation(), but avoid it. Other code on the page, including analytics and other components’ “click outside to close” logic, often relies on events reaching the document.
Preventing default behavior
Many events have a default action: a submitted form navigates to its action URL, a clicked link follows its href, and a checkbox toggles. event.preventDefault() cancels that default and leaves your code in charge.
Use it when you’re replacing the default with something that does the same job, like saving a form with JavaScript instead of a page reload. Avoid it when the default is what the person expects. A link whose click is canceled so a script can navigate breaks Cmd-click and Ctrl-click to open it in a new tab, unless the script rebuilds that behavior itself. If something navigates, keep it a link and let the browser do its job.
Meaningful events and convenient events
A convenient event is the one that’s easiest to attach. A meaningful event is the one that matches the person’s intent. They’re often different:
- Saving a form:
submitis meaningful, becauseclickon the button missesrequestSubmit()and ignores validation. - A checkbox:
changemeans the choice is made, andclickhappens to fire too, but it describes the pointer rather than the decision. - A custom “button” built from a
<div>: aclicklistener on it is convenient, but the element can’t be focused and doesn’t respond to Enter or Space. A real<button>makesclickmean “activated by any input method.”
Choosing the right element and the right event is where accessible behavior starts. The browser does a lot of the work when you let it.
The smallest working version
The pattern is a password visibility toggle. The button flips the field between hidden and visible text, and aria-pressed tells assistive technology whether the toggle is on.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title>
<link rel="stylesheet" href="styles.css">
<script type="module" src="app.js"></script>
</head>
<body>
<form data-sign-in-form>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" data-password-input>
<button type="button" data-password-toggle aria-pressed="false" hidden>Show password</button>
<button type="submit">Sign in</button>
</form>
</body>
</html>
// app.js
const passwordInput = document.querySelector('[data-password-input]');
const toggle = document.querySelector('[data-password-toggle]');
let isVisible = false;
toggle.hidden = false;
toggle.addEventListener('click', () => {
isVisible = !isVisible;
passwordInput.type = isVisible ? 'text' : 'password';
toggle.setAttribute('aria-pressed', String(isVisible));
});
The button starts hidden because it does nothing without the script. The styles.css file needs the [hidden] { display: none !important; } rule from chapter 1.
Accessibility check
Keyboard. Tab from the password field to the toggle and press Space, then Enter. Both reveal the password, and focus stays on the button, so the person can toggle again or press Shift+Tab to go back to the field.
Screen reader. The button is announced as “Show password, toggle button, not pressed,” then “pressed” after activation. The label stays the same because aria-pressed carries the state.
What fails. Designs often show this toggle as an eye icon with no text. A common first attempt is a clickable icon:
<span class="icon-eye" data-password-toggle></span>
With a click listener attached, it works with a mouse. A keyboard user can’t reach it, because a <span> isn’t focusable. A screen reader finds nothing at all, because it has no role and no name. Even changed to a <button> with only an icon inside, it would be announced as “button” with no name.
The fix. Use a real <button>, and keep a text name even when the design shows only the icon. Give the icon aria-hidden="true" and put the text in a visually hidden span:
<button type="button" data-password-toggle aria-pressed="false" hidden>
<svg aria-hidden="true" class="icon"><!-- eye icon --></svg>
<span class="visually-hidden">Show password</span>
</button>
/* styles.css */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
The text is in the accessibility tree and off the screen, so the button is announced as “Show password, toggle button.”
Debugging challenge: the toggle that submits the form
Remove type="button" from the toggle, so it reads <button data-password-toggle aria-pressed="false" hidden>.
The symptom. Type a password and click “Show password.” The password may flash into view, then the page reloads, the fields are empty, and the address bar now ends with something like ?email=you%40example.com&password=hunter2. Your password is in the URL, and it will be in the browser history too.
Finding it. The reload is the main clue: something navigated the page. Open the Console, turn on “Preserve log” so messages survive the reload, and add a submit listener to see whether the form is being submitted:
document.querySelector('[data-sign-in-form]').addEventListener('submit', (event) => {
console.log('Submitted by', event.submitter);
});
Click the toggle again. The log shows the form was submitted, and event.submitter is the password toggle. That means the browser treats the toggle as a submit button. Inspect it in the Elements panel and you’ll see it has no type attribute. A <button> inside a form defaults to type="submit". The click listener runs and shows the password, and then the button’s default action submits the form. Since the form has no method, it uses GET, which puts every named field into the URL.
The fix. Put type="button" back. Every button that isn’t meant to submit its form needs it. Calling event.preventDefault() in the click handler would also stop the submission, but that hides the mistake instead of fixing it. The toggle would still be the form’s first submit button, so pressing Enter in the email field would click the toggle instead of signing in.
This works because…
This works because the toggle is a real <button type="button">, so the browser makes it focusable, fires click for mouse, touch, Enter, and Space, and doesn’t submit the form. One boolean, isVisible, remembers whether the password is showing. Each click flips it, sets the input’s type to match, and updates aria-pressed so a screen reader can announce the new state. The button starts hidden in the HTML and the script reveals it, so people without JavaScript never see a control that does nothing.
Project: account settings page
The project is an account settings page with a display name, an email address, an optional new password with a visibility toggle, and notification preferences. Errors appear inline at the right moments, and the Save button is enabled only when something has changed.
Most interface work is many small behaviors working together, and this project is a good example. None of the pieces is complicated, but each one needs the right event.
Designers and engineers often disagree about when validation errors should appear. This book uses one rule throughout:
- Check a field when the person leaves it for the first time, on
blurorchange. - Once a field shows an error, re-check it on every
input, so the error disappears as soon as it’s fixed. - Check every field on submit.
- Never show an error on the first keystroke, before the person has had a chance to finish.
Starter HTML
<!-- index.html (body) -->
<form class="settings" data-settings-form novalidate>
<h1>Account settings</h1>
<div class="field">
<label for="display-name">Display name</label>
<input id="display-name" name="displayName" value="Jordan Lee" required
aria-describedby="display-name-error" data-validate>
<p id="display-name-error" class="field-error" data-error-for="displayName" hidden></p>
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" value="jordan@example.com" required
autocomplete="email" aria-describedby="email-error" data-validate>
<p id="email-error" class="field-error" data-error-for="email" hidden></p>
</div>
<div class="field">
<label for="new-password">New password (optional)</label>
<input id="new-password" name="newPassword" type="password" autocomplete="new-password"
aria-describedby="new-password-hint new-password-error" data-validate data-password-input>
<button type="button" data-password-toggle aria-pressed="false" hidden>Show password</button>
<p id="new-password-hint">At least 12 characters. Leave blank to keep your current password.</p>
<p id="new-password-error" class="field-error" data-error-for="newPassword" hidden></p>
</div>
<fieldset>
<legend>Notifications</legend>
<label><input type="checkbox" name="productUpdates" checked> Product updates</label>
<label><input type="checkbox" name="securityAlerts" checked> Security alerts</label>
<label><input type="checkbox" name="weeklySummary"> Weekly summary</label>
</fieldset>
<div class="actions">
<button type="submit" data-save>Save changes</button>
<p data-dirty-hint>No unsaved changes.</p>
<p role="status" data-save-status></p>
</div>
</form>
The novalidate attribute turns off the browser’s own error bubbles, so the page can show its own messages with the timing rule above. The role="status" paragraph is in the page from the start, empty, so a screen reader is already watching it when “Settings saved” is written into it. Chapter 6 goes much further with forms, including the browser’s constraint validation API, and chapter 9 covers live regions.
Key JavaScript
The password toggle is the same code as the smallest working version, so it’s left out here. The rest starts by recording the saved values, so the page can tell when something has changed:
// app.js
const form = document.querySelector('[data-settings-form]');
const saveButton = form.querySelector('[data-save]');
const dirtyHint = form.querySelector('[data-dirty-hint]');
const saveStatus = form.querySelector('[data-save-status]');
function readValues() {
return {
displayName: form.elements.displayName.value.trim(),
email: form.elements.email.value.trim(),
newPassword: form.elements.newPassword.value,
productUpdates: form.elements.productUpdates.checked,
securityAlerts: form.elements.securityAlerts.checked,
weeklySummary: form.elements.weeklySummary.checked,
};
}
let savedValues = readValues();
function updateSaveButton() {
const isDirty = JSON.stringify(readValues()) !== JSON.stringify(savedValues);
saveButton.setAttribute('aria-disabled', String(!isDirty));
dirtyHint.textContent = isDirty ? 'You have unsaved changes.' : 'No unsaved changes.';
}
form.elements.displayName finds the control whose name is displayName. JSON.stringify turns each object into a string, which is a simple way to compare every value at once, because both objects always list their properties in the same order.
Each field’s rules live in one function, which returns an error message or an empty string. A second function shows or clears the message and keeps aria-invalid in step with it:
function getError(input) {
const value = input.value.trim();
if (input.name === 'displayName' && value === '') {
return 'Enter a display name.';
}
if (input.name === 'email' && value === '') {
return 'Enter an email address.';
}
if (input.name === 'email' && input.validity.typeMismatch) {
return 'Enter an email address in the format name@example.com.';
}
if (input.name === 'newPassword' && input.value !== '' && input.value.length < 12) {
return 'Use at least 12 characters, or leave this blank to keep your current password.';
}
return '';
}
function showError(input, message) {
const error = form.querySelector(`[data-error-for="${input.name}"]`);
error.textContent = message;
error.hidden = message === '';
if (message === '') {
input.removeAttribute('aria-invalid');
} else {
input.setAttribute('aria-invalid', 'true');
}
}
input.validity.typeMismatch is the browser’s own check for a type="email" field, which is still available when novalidate is on.
Three delegated listeners on the form carry out the timing rule. focusout bubbles, so one listener covers every field leaving focus. The input listener only re-checks a field that’s already showing an error, so nobody sees an error mid-word:
form.addEventListener('focusout', (event) => {
if (event.target.matches('[data-validate]')) {
showError(event.target, getError(event.target));
}
});
form.addEventListener('input', (event) => {
const input = event.target;
if (input.matches('[data-validate]') && input.getAttribute('aria-invalid') === 'true') {
showError(input, getError(input));
}
saveStatus.textContent = '';
updateSaveButton();
});
form.addEventListener('change', updateSaveButton);
The change listener makes sure checkbox changes update the Save button. Current browsers also fire input for checkboxes, so it’s a second path to the same result, and calling updateSaveButton twice is harmless.
On submit, every field is checked. If any has an error, focus moves to the first one so keyboard and screen reader users land on the problem. Otherwise the values become the new saved values:
form.addEventListener('submit', (event) => {
event.preventDefault();
if (saveButton.getAttribute('aria-disabled') === 'true') return;
let firstInvalid = null;
for (const input of form.querySelectorAll('[data-validate]')) {
const message = getError(input);
showError(input, message);
if (message !== '' && firstInvalid === null) {
firstInvalid = input;
}
}
if (firstInvalid !== null) {
firstInvalid.focus();
return;
}
// A real page would send the values to a server here. Chapter 7 covers that.
form.elements.newPassword.value = '';
savedValues = readValues();
updateSaveButton();
saveStatus.textContent = 'Settings saved.';
});
updateSaveButton();
The new password field is cleared before the values are saved, so the saved snapshot and the form agree and Save goes back to unavailable. Save uses aria-disabled instead of the disabled property because Save has focus right after someone clicks it, and disabling a focused button drops focus to the page body. aria-disabled tells assistive technology the button is unavailable while leaving it focusable, and the guard at the top of the submit handler makes that true. Style it with [aria-disabled="true"] so it looks unavailable as well.
Done means
- The password toggle is a
<button type="button">with a name,aria-pressed, and no effect on form submission. - No error appears while someone is typing in a field for the first time.
- Leaving a field that’s invalid shows its error, and fixing the field clears the error without leaving it.
- Submitting checks every field and moves focus to the first invalid one.
- Each error is connected to its field with
aria-describedby, and invalid fields havearia-invalid="true". A screen reader announces the error when you move back to the field. - Save is marked unavailable with
aria-disabledwhen nothing has changed, available after any change, and unavailable again if you change a value back. - The “No unsaved changes” hint is visible text next to Save and explains why it does nothing.
- Saving announces “Settings saved.” through the status region.
- Every listener uses the meaningful event:
submitfor saving,inputfor live updates,focusoutfor leaving a field, andchangefor checkboxes. - You’ve written a “This works because…” paragraph that explains the validation timing and how the page decides whether there are unsaved changes.
Chapter 3 takes a closer look at what this project kept in savedValues and isVisible: the state an interface remembers, and how to keep it in one place.
Found an error? Report it.