Chapter 1
The browser is the environment
How JavaScript reads and changes a page through the DOM, from selecting elements to updating text, classes, attributes, and data attributes, and a profile card customizer that enhances a page that already works.
A shopper is looking at a product page and wants to know when an order will arrive. The shipping policy is long, so the page shows a short summary and a button that reveals the full details. When the shopper activates the button, the details appear, and the button reports that it is now expanded. If the script never loads, the details are already on the page and readable.
That small interaction carries the main idea of this chapter: the browser is the environment your JavaScript runs in. The page, the form controls, focus, the address bar, and the accessibility information a screen reader reads all exist before your script starts. Your code doesn’t build the interface from nothing. It reads what the browser has already built and changes it in response to what people do.
What JavaScript is responsible for
HTML, CSS, and JavaScript each have a job, and interfaces are easier to build and debug when each one sticks to it.
- HTML is structure and meaning. It says this is a heading, this is a button, this is a form field and this is its label. The browser uses that meaning for keyboard behavior, form submission, and the information it gives assistive technology.
- CSS is presentation. It decides how things look in each state: open or closed, selected or not, light theme or dark.
- JavaScript is behavior. It listens for what people do, remembers what has changed, and updates the page so the HTML and CSS describe the new situation.
When JavaScript takes over the other two jobs, by building markup that could have been in the HTML or setting colors through inline styles, the result is harder to change. Before writing code, ask what the HTML already provides. A <button> is focusable and responds to Enter and Space with no script at all, and a <details> element opens and closes on its own. Using what the browser gives you means writing less JavaScript.
Of the four questions from the introduction, this chapter is mostly about the third: what should update on the screen. State and events appear in small doses here, and each gets its own chapter next.
The DOM is not your HTML file
When the browser loads your HTML file, it reads the text and builds a tree of objects in memory. That tree is the Document Object Model, or DOM. Every element, attribute, and piece of text in the page becomes a node in the tree, and JavaScript works with those nodes rather than the file.
The difference matters in two ways.
First, the browser fixes and fills in your markup while it builds the tree. If you write a table row directly inside a <table>, the DOM will contain a <tbody> you never typed:
<!-- What you wrote -->
<table>
<tr><td>Standard shipping</td><td>$5</td></tr>
</table>
Open the Elements panel in your browser’s developer tools and you’ll see <table>, then <tbody>, then <tr>. The Elements panel shows the live DOM. View Source shows the file the server sent. When they disagree, the Elements panel is the one your code is working with.
Second, every change your script makes happens in the DOM. When you change a heading’s text with JavaScript, the Elements panel shows the new text immediately, but View Source and the file on disk stay exactly as they were. Reload the page and the change is gone, because the browser builds a fresh DOM from the file.
The document and the window
document is the root of the DOM. You’ll use it to find elements, and it’s where page-wide information like document.title lives.
window represents the browser tab the page is running in. It holds things that belong to the tab rather than the page content: the current URL in window.location, the viewport width in window.innerWidth, storage in window.localStorage, and timers like setTimeout. The window object is also the global scope, which is why you can write document instead of window.document.
Try this in the browser console on any page:
console.log(document.title);
console.log(window.location.href);
console.log(window.innerWidth);
Finding elements
Before you can change an element, you need a reference to it. document.querySelector() takes a CSS selector and returns the first matching element, or null if nothing matches. document.querySelectorAll() returns every match as a list you can loop over.
const heading = document.querySelector('h1');
const buttons = document.querySelectorAll('button');
console.log(heading.textContent);
console.log(buttons.length);
Any selector you can write in CSS works here. In this book, scripts find elements through data-* attributes:
<p class="notice notice--warning" data-save-notice>Your changes are not saved.</p>
const notice = document.querySelector('[data-save-notice]');
Classes belong to styling, and they change when the design changes. If a redesign renames notice--warning, a script that selected it breaks silently. A data-save-notice attribute tells anyone reading the HTML that a script depends on this element.
You can also search inside an element: card.querySelector('[data-name]') only looks at the card’s descendants, which avoids grabbing the wrong element when a page has several similar components.
Where the script tag goes
Every example in this book loads its script like this:
<script type="module" src="app.js"></script>
Module scripts are deferred by default: the browser runs them after the whole document has been parsed, so a script in the <head> can still find every element in the page. A classic <script> without type="module" or defer runs as soon as the parser reaches it, and a query for an element further down the page returns null.
Changing text
The pattern here is updating text on the page, such as a name, a count, or a status message. The textContent property reads and writes an element’s text.
const status = document.querySelector('[data-save-status]');
status.textContent = 'All changes saved';
Setting textContent replaces everything inside the element with plain text. A string containing <b> shows up as those literal characters and is never parsed as HTML, which is what you want for any text from a user or a server. Chapter 5 shows why innerHTML is dangerous for that kind of content.
Adding and removing classes
The pattern is adding and removing a class so CSS can style a state. Every element has a classList with methods for this:
const card = document.querySelector('[data-plan-card]');
card.classList.add('is-selected');
card.classList.remove('is-selected');
card.classList.toggle('is-selected'); // add if missing, remove if present
card.classList.toggle('is-selected', true); // force it on
console.log(card.classList.contains('is-selected'));
The two-argument form of toggle() is the one you’ll use most. Instead of flipping the class and hoping it ends up right, you pass the state: card.classList.toggle('is-selected', isSelected). JavaScript says which state the element is in, and the stylesheet decides what that state looks like.
Changing attributes
Several patterns in this chapter come down to attributes: showing and hiding a message, enabling and disabling a button, and telling assistive technology about a state.
const button = document.querySelector('[data-submit]');
button.setAttribute('aria-describedby', 'submit-help');
console.log(button.getAttribute('aria-describedby'));
button.removeAttribute('aria-describedby');
Many common attributes also have a matching property, which is usually simpler to use:
button.disabled = true; // adds the disabled attribute
button.disabled = false; // removes it
const message = document.querySelector('[data-message]');
message.hidden = true; // adds the hidden attribute
message.hidden = false; // removes it
For ARIA attributes, use setAttribute() and pass a string, as in button.setAttribute('aria-expanded', 'true'). ARIA values are strings, so String(isOpen) is a reliable way to turn a boolean into 'true' or 'false'.
Attributes and properties are not always the same thing. For a text input, the value attribute is the starting value written in the HTML, and the value property is whatever is in the field right now. After someone types, input.value returns their text while input.getAttribute('value') still returns the original. When you want the current value of a form control, read the property.
Hiding with the hidden attribute
The hidden attribute removes an element from the page and from the accessibility tree, so screen readers skip it too. This book uses hidden instead of toggling display, because the attribute states the intent in the HTML and works without any CSS.
There is one catch. The browser hides [hidden] elements with a default style of display: none, and that default has very low priority. Any rule in your own stylesheet that sets display on the same element wins, and the element stays visible even though it has the hidden attribute. Add this rule near the top of every stylesheet you write:
[hidden] {
display: none !important;
}
You’ll see what happens without it in the debugging challenge later in this chapter.
Reading configuration from data attributes
The last pattern is reading data from an element. Data attributes can hold small pieces of configuration, so the HTML decides the details and the script stays general. Each data-* attribute is available on the element’s dataset property, with the name converted to camelCase:
<textarea data-bio data-max-length="160" data-fallback-text="No bio yet"></textarea>
const bio = document.querySelector('[data-bio]');
console.log(bio.dataset.maxLength); // "160"
console.log(bio.dataset.fallbackText); // "No bio yet"
data-max-length becomes dataset.maxLength, and data-fallback-text becomes dataset.fallbackText. Every value comes back as a string, so convert it when you need a number: Number(bio.dataset.maxLength) gives you 160.
Progressive enhancement
The product page from the opening shows the principle this chapter keeps returning to: the page exists first, and JavaScript enhances it. Write the HTML so that the content is readable and the important actions work without any script. Then add JavaScript to improve the experience for people whose browser runs it.
Scripts fail more often than you might expect. A file can fail to download on a poor connection, an extension can block it, or a syntax error can stop it. Progressive enhancement means those failures leave someone with a plainer page instead of a broken one.
In practice this changes where you put the starting state. If the script is going to hide content behind a button, the HTML should show the content and the script should hide it. If a button only works with JavaScript, the HTML should hide the button and the script should reveal it. You’ll apply both rules in the smallest working version below.
The accessibility tree
The browser builds a second structure from the DOM: the accessibility tree. It contains what assistive technology needs to know about each meaningful element, mostly three things:
- Role: what kind of thing it is, such as a button, a link, a heading, or a checkbox.
- Name: what it’s called, usually from its text content or its
<label>. - State: its current condition, such as checked, expanded, disabled, or pressed.
Screen readers, voice control software, and other assistive tools read this tree rather than the visual page. A <button>Shipping details</button> appears as a button named “Shipping details.” A <div> styled to look like a button appears as plain text, so a screen reader user doesn’t know they can activate it.
Your JavaScript changes this tree whenever it changes the DOM. Setting hidden removes an element from it, setting aria-expanded changes a button’s state, and changing textContent can change an element’s name. You can inspect the tree in the Accessibility tab of Chrome’s Elements panel or with Firefox’s Accessibility Inspector. Chapter 9 goes much deeper into roles, names, and states.
The smallest working version
Here is the shipping details button from the opening. It uses a boolean variable to remember whether the details are open, a click listener to respond to the button, and the hidden property to show and hide the details. Chapter 2 explains event listeners in full. For now, addEventListener('click', ...) means “when this button is activated, run this code.”
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field notebook</title>
<link rel="stylesheet" href="styles.css">
<script type="module" src="app.js"></script>
</head>
<body>
<h1>Field notebook</h1>
<p>$24. Ships in 2 business days.</p>
<button type="button" class="details-toggle" data-details-toggle>
Shipping details
</button>
<div class="details" data-details hidden>
<p>Orders placed before 2 p.m. ship the same day. Standard delivery takes 3 to 5 business days after shipping.</p>
</div>
</body>
</html>
/* styles.css */
[hidden] {
display: none !important;
}
.details-toggle::after {
content: "";
display: inline-block;
margin-inline-start: 0.5em;
border: 0.3em solid transparent;
border-top-color: currentColor;
vertical-align: middle;
}
.details-toggle.is-open::after {
transform: rotate(180deg);
}
// app.js
const toggle = document.querySelector('[data-details-toggle]');
const details = document.querySelector('[data-details]');
let isOpen = false;
toggle.addEventListener('click', () => {
isOpen = !isOpen;
details.hidden = !isOpen;
toggle.classList.toggle('is-open', isOpen);
});
Click the button and the details appear. Click again and they disappear. It works with a mouse, and it looks finished. The next section checks whether it is.
Accessibility check
Test the page three ways: with a keyboard, with a screen reader, and with JavaScript turned off.
Keyboard. Press Tab until the button has focus, then press Enter, then Space. Both keys toggle the details, because a real <button> responds to them with no extra code. This part passes.
Screen reader. Turn on VoiceOver (macOS), NVDA (Windows), or another screen reader and move to the button. You’ll hear “Shipping details, button” before and after you activate it. Nothing tells a screen reader user whether the details are open. The rotating arrow shows the state visually, but it’s an empty CSS shape with nothing for assistive technology to read. Someone who can’t see the page has to go looking for the content to find out whether the button did anything.
JavaScript off. In Chrome’s developer tools, open the command menu (Cmd+Shift+P on macOS, Ctrl+Shift+P on Windows), run “Disable JavaScript,” and reload. The details are hidden by the hidden attribute in the HTML, and there’s no script to reveal them. The shipping policy has become unreadable, and the button that remains does nothing.
The fix addresses both problems. Give the button an aria-expanded state, and move the starting state into the script so the HTML works on its own:
<!-- index.html (changed lines) -->
<button type="button" class="details-toggle" data-details-toggle hidden aria-expanded="false">
Shipping details
</button>
<div class="details" data-details>
<p>Orders placed before 2 p.m. ship the same day. Standard delivery takes 3 to 5 business days after shipping.</p>
</div>
// app.js
const toggle = document.querySelector('[data-details-toggle]');
const details = document.querySelector('[data-details]');
let isOpen = false;
// The script is running, so switch from the no-script layout to the enhanced one.
details.hidden = true;
toggle.hidden = false;
toggle.addEventListener('click', () => {
isOpen = !isOpen;
details.hidden = !isOpen;
toggle.classList.toggle('is-open', isOpen);
toggle.setAttribute('aria-expanded', String(isOpen));
});
Now a screen reader announces “Shipping details, button, collapsed,” then “expanded” after activation. Without JavaScript, the button stays hidden and the policy is visible. The button’s label stays the same in both states because aria-expanded already carries the state, and changing both the label and the state would make a screen reader announce the change twice.
Debugging challenge: the details that won’t hide
Here is a change that a designer might make while styling the page. Add a layout rule for the details panel:
/* styles.css */
.details {
display: grid;
gap: 0.5rem;
}
Also delete the [hidden] rule from the top of the file, as if you had started a new stylesheet and forgotten it.
The symptom. The details are visible when the page loads, and clicking the button changes nothing you can see. The script doesn’t report any errors.
Finding it. First find out whether the script is running. Open the Elements panel, select the details <div>, and click the button a few times. The hidden attribute appears and disappears on the element each time, and aria-expanded on the button flips between "true" and "false". So the script is doing its job, and the DOM is correct. The problem is between the DOM and the screen, which points at CSS.
With the <div> still selected, look at the Styles pane while it has the hidden attribute. You’ll see the browser’s own rule, [hidden] { display: none; }, labeled as the user agent stylesheet, with a line through it. Above it, your .details { display: grid; } rule wins, because author styles beat the browser’s default styles. The attribute is present, but nothing is hiding the element.
The fix. Put the [hidden] { display: none !important; } rule back. It wins over any display value you set elsewhere, so the attribute always does what it says.
A second common bug looks similar at first. Change the selector in app.js to '[data-detail-toggle]', missing the “s.” This time the console shows an error like “Cannot set properties of null (setting ‘hidden’).” The word null tells you querySelector found nothing, so check that the selector in the script matches the attribute in the HTML character for character.
This works because…
This works because the HTML is complete before the script runs. The shipping policy is ordinary content, so it’s readable when JavaScript fails. When the script does run, it finds the button and the details through data attributes, switches the page into its enhanced layout, and keeps one boolean, isOpen, as the record of whether the details are showing. Each click flips that boolean and then updates three things to match it: the hidden attribute on the details, the class that CSS uses for styling, and the aria-expanded state that a screen reader announces. The button is a real <button>, so the keyboard support comes from the browser.
If a sentence in a paragraph like this is hard to finish, that’s the part of the code to go back and study.
Project: profile card customizer
The project is a profile card with a small panel of controls beside it. The card is a normal piece of HTML that reads correctly on its own. When JavaScript runs, the customizer appears, and the visitor can change the name and job title on the card, choose a theme, and show or hide the optional details. If either text field is empty, the card shows a placeholder taken from a data attribute, and a reset button puts everything back.
Every pattern from this chapter shows up in it: finding elements, updating text, changing classes and attributes, hiding with hidden, reading data-* configuration, and enhancing a page that already works.
Starter HTML
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Profile card customizer</title>
<link rel="stylesheet" href="styles.css">
<script type="module" src="app.js"></script>
</head>
<body>
<main class="layout">
<article class="profile-card theme-light" data-profile-card>
<h2 class="profile-card__name" data-profile-name>Jordan Lee</h2>
<p class="profile-card__title" data-profile-title>Product designer</p>
<dl class="profile-card__details" data-profile-details>
<dt>Location</dt>
<dd>Chicago</dd>
<dt>Pronouns</dt>
<dd>they/them</dd>
</dl>
</article>
<section class="customizer" data-customizer hidden aria-labelledby="customizer-heading">
<h2 id="customizer-heading">Customize the card</h2>
<label for="name-input">Name</label>
<input id="name-input" type="text" maxlength="40" data-name-input data-fallback="Your name">
<label for="title-input">Job title</label>
<input id="title-input" type="text" maxlength="60" data-title-input data-fallback="Your role">
<label for="theme-select">Theme</label>
<select id="theme-select" data-theme-select>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="ocean">Ocean</option>
</select>
<label>
<input type="checkbox" data-details-checkbox>
Show location and pronouns
</label>
<button type="button" data-reset>Reset card</button>
</section>
</main>
</body>
</html>
The customizer starts with hidden because it does nothing without JavaScript. The card doesn’t depend on the script at all.
The themes are classes on the card, and the stylesheet decides what each looks like:
/* styles.css (excerpt) */
[hidden] {
display: none !important;
}
.profile-card.theme-light { background: #ffffff; color: #1f2328; }
.profile-card.theme-dark { background: #1f2328; color: #f6f8fa; }
.profile-card.theme-ocean { background: #e6f2f8; color: #0b3a53; }
Key JavaScript
The script keeps everything it knows about the card in one profile object. Each control updates that object, and then a render function makes the card match it. Chapter 3 explains why this shape pays off as interfaces grow.
The starting values come from the page itself, which shows the “page exists first” idea in code:
// app.js
const card = document.querySelector('[data-profile-card]');
const nameOutput = card.querySelector('[data-profile-name]');
const titleOutput = card.querySelector('[data-profile-title]');
const details = card.querySelector('[data-profile-details]');
const customizer = document.querySelector('[data-customizer]');
const nameInput = customizer.querySelector('[data-name-input]');
const titleInput = customizer.querySelector('[data-title-input]');
const themeSelect = customizer.querySelector('[data-theme-select]');
const detailsCheckbox = customizer.querySelector('[data-details-checkbox]');
const resetButton = customizer.querySelector('[data-reset]');
// Read the starting values from the card that is already on the page.
const original = {
name: nameOutput.textContent,
title: titleOutput.textContent,
theme: 'light',
showDetails: true,
};
// A separate copy that the controls can change.
const profile = { ...original };
The render function writes the profile onto the card. The fallback text comes from each input’s data-fallback attribute, and the list of theme classes comes from the <select> options, so adding a theme only takes a new <option> and a CSS rule:
function render() {
nameOutput.textContent = profile.name.trim() || nameInput.dataset.fallback;
titleOutput.textContent = profile.title.trim() || titleInput.dataset.fallback;
for (const option of themeSelect.options) {
card.classList.remove(`theme-${option.value}`);
}
card.classList.add(`theme-${profile.theme}`);
details.hidden = !profile.showDetails;
}
The || operator uses the value on its left unless that value is empty, in which case it uses the value on its right. An empty or all-spaces name becomes the fallback text.
The controls need to show the same values as the profile when the page loads and after a reset, so one function fills them in:
function fillControls() {
nameInput.value = profile.name;
titleInput.value = profile.title;
themeSelect.value = profile.theme;
detailsCheckbox.checked = profile.showDetails;
}
The listeners update the profile and then call render. Text fields use the input event, which fires on every edit, so the card updates as the visitor types. The select and checkbox use change, which fires when the choice is made. Chapter 2 covers how to choose between them.
nameInput.addEventListener('input', () => {
profile.name = nameInput.value;
render();
});
titleInput.addEventListener('input', () => {
profile.title = titleInput.value;
render();
});
themeSelect.addEventListener('change', () => {
profile.theme = themeSelect.value;
render();
});
detailsCheckbox.addEventListener('change', () => {
profile.showDetails = detailsCheckbox.checked;
render();
});
resetButton.addEventListener('click', () => {
Object.assign(profile, original);
fillControls();
render();
});
// The script is running, so show the controls and sync everything.
fillControls();
render();
customizer.hidden = false;
Object.assign(profile, original) copies every property from original onto profile, which puts the card back to how it started.
Done means
- With JavaScript disabled, the card reads correctly and the customizer is not visible.
- With JavaScript enabled, changing each control updates the card, and clearing a text field shows its fallback text.
- No script selects an element by class name. Every hook is a
data-*attribute. - All text on the card is set with
textContent. - Each control has a visible
<label>, and the checkbox label is clickable. - You can reach and use every control with Tab, Shift+Tab, arrow keys in the select, and Space on the checkbox.
- In the browser’s accessibility inspector, the hidden details are absent from the tree when unchecked and present when checked.
- Every theme has enough contrast between text and background. Check it with the contrast tool in your browser’s developer tools.
- The card is not a live region. A screen reader user can move to the card to hear the result, and nothing is announced on every keystroke.
- You’ve written a “This works because…” paragraph that explains where the starting values come from, what
renderdoes, and why the customizer starts hidden.
The next chapter looks closely at the part this chapter used without explaining: events, and how to choose the one that matches what the person actually meant to do.
Found an error? Report it.