To change a page with JavaScript, you first have to find the part you want to change. This lesson explains the DOM, the browser's live model of your page, and shows how to select elements from it. You will also set up the quiz app that you build over the next five lessons.
What the DOM is
When a browser loads HTML, it does not keep the text of your file. It parses it into a tree of objects called the Document Object Model, or DOM. Every element becomes an object, called a node, with properties and methods you can use from JavaScript.
Take this HTML:
<main>
<h1>Quiz</h1>
<p>Answer three questions.</p>
</main>
The browser builds a tree like this:
main
├── h1
│ └── "Quiz"
└── p
└── "Answer three questions."
The main element is the parent of h1 and p, which are its children and each other's siblings. The text inside each element is a node too, called a text node.
Three things are worth knowing about the DOM:
- It is live. When JavaScript changes the DOM, the browser updates the screen to match. That is how every interactive page works.
- It is not your HTML file. The browser fixes mistakes as it parses, and scripts can change the tree afterwards. The Elements panel in DevTools shows the current DOM, not your source.
- It is provided by the browser. The DOM is a Web API, not part of the JavaScript language. That is why
documentdoes not exist in Node.js.
The whole tree is reachable through the global document object. Try these in the console on any page:
document.title
// → the page title
document.body
// → the body element
document.documentElement
// → the html element
Setting up the quiz app
Over lessons 14 to 18 you will build a small quiz. Create a folder called quiz with two files. First, quiz/index.html:
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JavaScript quiz</title>
<script src="quiz.js" defer></script>
</head>
<body>
<main>
<h1>JavaScript quiz</h1>
<section id="quiz">
<p id="progress">Question 1 of 3</p>
<h2 id="question-text">Question text goes here</h2>
<ul class="options">
<li><button type="button" class="option">Option A</button></li>
<li><button type="button" class="option">Option B</button></li>
<li><button type="button" class="option">Option C</button></li>
</ul>
<p id="status" role="status" aria-live="polite"></p>
<button type="button" id="next-button">Next question</button>
</section>
</main>
</body>
</html>
The options are real button elements, so they work with a keyboard and screen readers without extra code. The status paragraph is an ARIA live region: when JavaScript later puts "Correct!" in it, screen readers announce the change without moving focus. It starts empty on purpose, because live regions announce changes, and must exist in the page before the change happens. The Web Accessibility Essentials course covers live regions in depth.
Then quiz/quiz.js, with the questions held in an array of objects, as in lesson 13:
const questions = [
{
id: 1,
text: "Which keyword declares a variable that cannot be reassigned?",
options: ["let", "const", "var"],
answer: "const",
},
{
id: 2,
text: "What does typeof null return?",
options: ["\"null\"", "\"object\"", "\"undefined\""],
answer: "\"object\"",
},
{
id: 3,
text: "Which loop is best for the values of an array?",
options: ["for...in", "for...of", "do...while"],
answer: "for...of",
},
];
The script is loaded with defer, so it runs after the HTML has been parsed and every element exists. You will add the selection code below to this file.
querySelector: the first match
document.querySelector() takes any CSS selector and returns the first element that matches:
const questionText = document.querySelector("#question-text");
const firstOption = document.querySelector(".option");
const heading = document.querySelector("h1");
console.log(questionText.textContent);
// Question text goes here
console.log(firstOption.textContent);
// Option A
Because it accepts any selector you would write in CSS, it can be as precise as you need:
const nextButton = document.querySelector("#quiz > button");
const secondOption = document.querySelector(".options li:nth-child(2) button");
Keep selectors simple and tied to meaning. A selector that depends on the exact nesting of the page breaks when someone moves an element. An id or a class chosen for the purpose is sturdier.
When nothing matches
If no element matches, querySelector() returns null:
const missing = document.querySelector("#does-not-exist");
console.log(missing);
// null
missing.textContent = "Hello";
// Uncaught TypeError: Cannot set properties of null (setting 'textContent')
This error usually means one of three things: a typo in the selector, a forgotten # or ., or the script running before the element exists, which defer prevents. If an element is genuinely optional, check before using it:
const banner = document.querySelector(".promo-banner");
if (banner) {
banner.hidden = true;
}
An invalid selector, such as "#" on its own, throws a SyntaxError instead of returning null.
getElementById
document.getElementById() finds the element with a given id. Pass the id without a #:
const statusRegion = document.getElementById("status");
const nextButton = document.getElementById("next-button");
It returns the element or null, just like querySelector("#status"). It is slightly faster, though the difference is too small to matter in practice. Use whichever reads better to you, and be consistent; this course mostly uses querySelector so that every selection looks the same.
querySelectorAll: every match
document.querySelectorAll() returns all matching elements, in document order, as a NodeList:
const optionButtons = document.querySelectorAll(".option");
console.log(optionButtons.length);
// 3
console.log(optionButtons[0].textContent);
// Option A
If nothing matches, you get an empty NodeList with a length of 0, never null. So you do not need a null check, but a typo silently gives you nothing to work with.
Looping over a NodeList
A NodeList is not an array, but it is iterable, so for...of works:
for (const button of optionButtons) {
console.log(button.textContent);
}
// Option A
// Option B
// Option C
It also has a forEach() method:
optionButtons.forEach((button, index) => {
console.log(`${index}: ${button.textContent}`);
});
// 0: Option A
// 1: Option B
// 2: Option C
It does not have array methods such as map() or filter(). Convert it to an array first when you need them:
const labels = Array.from(optionButtons).map((button) => button.textContent);
console.log(labels);
// ["Option A", "Option B", "Option C"]
You will meet array methods properly in lesson 22.
A static snapshot
The NodeList from querySelectorAll() is static: it is a snapshot of the matches at the moment you called it. If you add another .option button to the page later, the existing NodeList does not include it. Call querySelectorAll() again when you need an up-to-date list.
Older methods such as getElementsByClassName() and getElementsByTagName() return a live HTMLCollection that updates automatically. That sounds convenient, but it causes surprising bugs when you change the page while looping. Prefer querySelectorAll().
Searching inside an element
querySelector() and querySelectorAll() are also methods of every element. Called on an element, they only search inside it:
const quiz = document.querySelector("#quiz");
const quizButtons = quiz.querySelectorAll("button");
console.log(quizButtons.length);
// 4
That finds the three options and the Next button, but no buttons elsewhere on the page. Scoping searches like this makes code faster to reason about and safer when a page contains several similar components.
To go upwards instead, closest() finds the nearest ancestor, including the element itself, that matches a selector:
const firstOption = document.querySelector(".option");
console.log(firstOption.closest("section").id);
// quiz
You will use closest() with events in lesson 29.
Putting it together
Add this below the questions array in quiz/quiz.js:
const quiz = document.querySelector("#quiz");
const progress = quiz.querySelector("#progress");
const questionText = quiz.querySelector("#question-text");
const optionButtons = quiz.querySelectorAll(".option");
const statusRegion = quiz.querySelector("#status");
const nextButton = quiz.querySelector("#next-button");
console.log(`Found ${optionButtons.length} option buttons`);
console.log(`First question: ${questions[0].text}`);
Open quiz/index.html in a browser and look at the console:
Found 3 option buttons
First question: Which keyword declares a variable that cannot be reassigned?
Selecting every element you need once, at the top of the file, and storing them in well-named const variables keeps the rest of your code readable. In the next lesson you will use these variables to show the first question and its options on the page.
In the Elements panel of DevTools, select an element and type $0 in the console. It refers to the element you selected, which is handy for testing selectors and properties.
Summary
- The DOM is the browser's live tree of objects built from your HTML, reached through
document. querySelector()returns the first element matching a CSS selector, ornullif none does.getElementById()finds an element by id, without the#.querySelectorAll()returns a static NodeList of every match, which may be empty but is nevernull.- Loop over a NodeList with
for...oforforEach(); useArray.from()to get array methods. - Call
querySelector()on an element to search only inside it, andclosest()to search upwards.