Intro to the JavaScript DOM: Selecting, Changing and Creating Elements
We meet the DOM tree the browser builds from HTML, select elements with querySelector, see why textContent and innerHTML differ and what XSS is, then change the page live with classList and createElement.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
Imagine you save the file below as predict.html and open it in your browser. The page has a heading with the id title, and the script is looking for exactly that heading.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Predict</title>
<script>
const title = document.getElementById("title");
console.log(title);
</script>
</head>
<body>
<h1 id="title">Hello DOM</h1>
</body>
</html>
What do you see in the console? The heading element itself, or something else? Make a guess before you keep reading. We'll answer it when we talk about exactly when a script runs.
From HTML to a Tree: What Is the DOM?
In the JSON post we saw earlier, we turned a piece of text into a real JavaScript object with JSON.parse. The browser does something similar with an HTML file. It reads the file from top to bottom, turns every tag into an object and links those objects together into a tree. That tree is called the DOM, the Document Object Model.
Think of it as a family tree. At the top sits <html>, and its two children are <head> and <body>. The headings, paragraphs and lists inside the body are its children, and the items inside those lists are its grandchildren. Every piece of the tree is called a node. Nodes made from tags are elements, and the text inside tags lives in text nodes.
There's an important distinction here: JavaScript never touches your HTML file. What it changes is the tree the browser keeps in memory. The browser reflects every change in that tree on the screen right away. That's why "view source" shows you the HTML as it first arrived, while the Elements tab in DevTools shows the tree as it is right now. When we painted the page gold with document.body.style.background in the first post, this is exactly what we were doing.
document: The Front Door to the Tree
You reach the whole tree through the document object. Open the console on any page and try these one by one:
document.documentElement; // the <html> element
document.head; // the <head> element
document.body; // the <body> element
document.title; // the title in the tab
A small DevTools trick: click any element in the Elements tab, then type $0 in the console. The element you selected comes right back. It's the fastest way to experiment with an element before you write code to find it.
Selecting Elements: getElementById and querySelector
Before you can change an element, you need to find it. Save this file, open it and look at the console:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Selectors</title>
</head>
<body>
<h1 id="title">Shopping List</h1>
<ul class="list">
<li class="item">Bread</li>
<li class="item on-sale">Milk</li>
<li class="item">Cheese</li>
</ul>
<script>
const title = document.getElementById("title");
const firstItem = document.querySelector(".item");
const onSale = document.querySelector("li.on-sale");
const allItems = document.querySelectorAll(".list .item");
console.log(title.textContent); // "Shopping List"
console.log(firstItem.textContent); // "Bread"
console.log(onSale.textContent); // "Milk"
console.log(allItems.length); // 3
allItems.forEach((item) => console.log(item.textContent));
</script>
</body>
</html>
getElementById is the oldest and most direct method: you give it an id and get back the element with that id. If it finds nothing, it returns null. querySelector accepts the same selectors you use in CSS: #id, .class, li.on-sale, .list .item. It returns the first matching element, or null again if nothing matches.
querySelectorAll gives you every matching element in a NodeList. A NodeList is not an array, but it's close: it has a length, you can read it by index, and you can loop over it with forEach and for...of. It doesn't have methods like map or filter. If you need them, turn it into a real array with the spread syntax we saw earlier: [...allItems].
You can call these methods on any element, not just on document. list.querySelector(".item") searches only inside that list. Also notice that we put the script at the very end of the body; we'll see shortly that there's a good reason for that.
Changing Content: textContent and innerHTML
Once you've found an element, there are two common ways to change what's inside it. With the shopping list page open, paste this into the console:
const h1 = document.querySelector("#title");
h1.textContent = "<em>Urgent</em> List";
// Shows up literally: <em>Urgent</em> List
h1.innerHTML = "<em>Urgent</em> List";
// "Urgent" is italic, the tag was parsed as HTML
textContent puts the value in as plain text. Even if it contains something that looks like a tag, the browser doesn't interpret it; it prints it character by character. innerHTML parses the value as HTML and builds new elements out of it. Reading works the same way: textContent gives you only the text, innerHTML gives you the content including the tags.
The difference looks innocent, but it matters a lot for security. Imagine you put a comment a user wrote onto the page with innerHTML. Try it in the console of the same page:
const box = document.querySelector("#title");
const comment = '<img src="x" onerror="alert(1)">';
box.innerHTML = comment; // The image fails to load, onerror runs: an alert opens
box.textContent = comment; // Shows up as plain text, nothing runs
This is called XSS (Cross-Site Scripting): an attacker gets their own code running on your page. A <script> tag inserted with innerHTML doesn't run, but that doesn't protect you; an onerror attribute like the one above is enough. Keep the rule simple: write anything that comes from a user, the address bar or a server with textContent. Use innerHTML only for HTML you wrote yourself, with no outside data in it.
You'll also run into innerText. It returns text too, but it takes the page's rendering into account; for example, it won't return text hidden with CSS. For most everyday work, textContent is the faster and more predictable choice.
Attributes, classList and style
Besides the text, you can change an element's attributes and its look. Save and open this file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Attributes and classList</title>
<style>
.box { padding: 16px; border: 2px solid gray; }
.active { border-color: green; background: honeydew; }
</style>
</head>
<body>
<div id="card" class="box">Card</div>
<a id="link" href="/about">About us</a>
<script>
const card = document.getElementById("card");
const link = document.getElementById("link");
console.log(link.getAttribute("href")); // "/about"
console.log(link.href); // the full address
link.setAttribute("title", "Who are we?");
card.classList.add("active");
console.log(card.classList.contains("active")); // true
card.classList.toggle("active"); // removes it if present, adds it if not
card.classList.toggle("active");
card.style.color = "darkred";
card.style.fontSize = "20px";
</script>
</body>
</html>
getAttribute and setAttribute work directly with what's written in the HTML. Many attributes also have a property with the same name, such as link.href. The output shows the subtle difference: the attribute gives you the /about you wrote, while the property gives you the full address the browser resolved.
For classes, use classList. add, remove, toggle and contains work on a single class without touching the others. Assigning a new value to className, on the other hand, replaces all of the element's classes at once, which is rarely what you want.
style changes the element's inline style. CSS names with dashes become camelCase here: fontSize instead of font-size, backgroundColor instead of background-color. A good habit: define your design in CSS as classes and only add or remove classes from JavaScript. Save style for values that really are calculated, like the width of a progress bar.
Creating, Adding and Removing Elements
You don't just change the DOM, you also grow new branches on it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Add and Remove</title>
</head>
<body>
<ul id="list">
<li>Bread</li>
<li id="milk">Milk</li>
</ul>
<script>
const list = document.getElementById("list");
const newItem = document.createElement("li");
newItem.textContent = "Olives";
list.append(newItem); // adds to the end
const first = document.createElement("li");
first.textContent = "Tomatoes";
list.prepend(first); // adds to the start
document.getElementById("milk").remove();
const fruits = ["Apple", "Pear", "Cherry"];
for (const fruit of fruits) {
const li = document.createElement("li");
li.textContent = fruit;
list.append(li);
}
</script>
</body>
</html>
createElement creates a new element in memory but doesn't put it on the page. The element stays invisible until you attach it somewhere in the tree. append adds it to the end, prepend to the start. Both accept several values and plain text: list.append(a, b, "text"). In older code you'll see appendChild; it does the same job but takes only one node. remove pulls the element out of wherever it is.
The loop at the end is probably the most useful pattern in this post: keep your data in an array, loop over it, create an element for each item and add it to the page. A product list, a comment feed, a search results page are basically bigger versions of this pattern. We'll cover more efficient ways of adding lots of elements in upcoming posts.
Common Pitfalls
When the script runs before the DOM
The answer to the opening question: you see null in the console. The browser reads HTML from top to bottom, and when it reaches a <script> tag it runs it immediately. While the script sits in the <head>, the browser hasn't reached the body yet, so there's no element called title in the tree. Had you gone one step further and tried to read title.textContent, you'd have got this error: TypeError: Cannot read properties of null (reading 'textContent').
There are two ways to fix it. Either put the script at the very end of the body, or keep an external file in the head with the defer attribute we saw earlier:
<head>
<meta charset="UTF-8">
<script src="app.js" defer></script>
</head>
defer tells the browser to download the script but not to run it until the whole page has been read. It only works for external files linked with src; add it to an inline script and it has no effect at all.
Treating a NodeList like an element
Writing document.querySelectorAll(".item").textContent = "None" doesn't throw an error, but it doesn't change anything either. The NodeList is the box, not the elements inside it. You have to make the change for each element in a loop.
Mixing up selector syntax
getElementById wants a plain id: getElementById("title"). querySelector wants a CSS selector: querySelector("#title"). Use one method's rule in the other and you silently get null.
innerHTML += inside a loop
list.innerHTML += "<li>New</li>" looks harmless, but every time, the browser turns the whole content of the list into text and rebuilds it from scratch. It's slow, and anything you attached to the old elements is lost, including the event listeners we'll meet in the next post. createElement and append don't have this problem.
A Small Experiment
★☆☆ In the shopping list file, select all the items with querySelectorAll and turn each one's text into upper case with the toUpperCase method we saw earlier.
★★☆ Predict before running, then try it in the console of any page:
const p = document.createElement("p");
p.textContent = "<b>Bold?</b>";
console.log(p.innerHTML);
console.log(document.body.contains(p));
★★★ Prepare an array of objects shaped like { name: "Pen", price: 40 }. Create an <li> for each product, write its text with textContent as "Pen: $40", and use classList to add the class expensive to anything priced above 100. Give that class a red color in CSS.
So far we've been the ones changing the page while the user just watched. In the next post we'll bring the user into the picture: with events, we'll write code that runs when a button is clicked, when someone types into a box or when a key is pressed.