Skip to content
aviral gupta

// B6.1 · ~30 min · Beginner

Selecting and changing elements

After this lesson you find elements on a page, change their text safely, and set their attributes, data values and classes from JavaScript.

Lesson 1 of 5 in B6 The DOM and events

Start of the module

You will be able to

  • Select elements with querySelector, querySelectorAll and getElementById, turn a NodeList into an array, and expect null when nothing matches
  • Change what an element shows with textContent, and explain why textContent, not innerHTML, is the safe choice for text from users
  • Read and change attributes, data-* values and classes with getAttribute, setAttribute, dataset and classList
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B5.3: what does this print?

    const list = ["tea", "milk"];
    const more = [...list, "eggs"];
    console.log(list.length, more.length);
  2. Predict · Activity 2 of 7

    Predict before you read on. The page has <p id="msg">Hi</p>. What does this print: how many elements are inside the paragraph, and what text?

    const msg = document.querySelector('#msg');
    msg.textContent = '<b>Bye</b>';
    console.log(msg.children.length, msg.textContent);
  3. Practice · Activity 3 of 7

    Fill in the method that finds the first element matching the CSS selector #title.

    const title = document.____('#title');
    console.log(title.textContent);
    const title = document.('#title');
  4. Practice · Activity 4 of 7

    Match each call to what it gives back.

  5. Practice · Activity 5 of 7

    The list is <ul id="list"><li class="tag">html</li>…</ul>. What does this print?

    const li = document.querySelector('#list li');
    li.classList.add('on');
    li.classList.remove('tag');
    console.log(li.classList.contains('tag'), li.className);
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The list starts with two items. What does the last line print?

    const items = document.querySelectorAll('#list li');
    const list = document.querySelector('#list');
    list.append(document.createElement('li'));
    console.log(items.length, list.children.length);
  7. Apply · Activity 7 of 7

    Mini-task: a todo heading that counts. Write an index.html with a heading <h1 id="heading"> and a list of three todos, each with data-done="yes" or "no". In main.js, select the items and spread them into an array, add the class done to the finished ones, and set the heading to "Todo: 2 of 3 done" with textContent. Print the todo names, and open the page through a local web server to see it.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A reading list that updates itself

The page has a heading, a list of three books with their page counts in data-pages, and a note. main.js selects the items, turns the NodeList into an array to read names and numbers, updates the heading, marks long books with a class and sets two attributes. Then it puts the same string into the note with textContent and with innerHTML, so you can see the difference, and asks for an element that does not exist.

main.js

// main.js runs after the HTML is parsed: module scripts are deferred.
const title = document.querySelector('#title');
const items = document.querySelectorAll('#books li');
console.log(items.length, typeof items.map);

// A NodeList has no map, so spread it into an array first.
const names = [...items].map((li) => li.textContent);
console.log(names);
const pages = [...items].map((li) => Number(li.dataset.pages));
console.log(pages);

title.textContent = 'My books (' + items.length + ')';
console.log(title.textContent);

for (const li of items) {
  if (Number(li.dataset.pages) > 400) li.classList.add('long');
}
items[0].dataset.read = 'yes';
items[2].setAttribute('title', 'A short one');
console.log(items[0].getAttribute('data-read'), items[1].classList.contains('long'));

// textContent shows text as it is; innerHTML reads it as HTML.
const note = document.getElementById('note');
note.textContent = '<b>2</b> long books';
console.log(note.textContent, note.children.length);
note.innerHTML = '<b>2</b> long books';
console.log(note.textContent, note.children.length);
console.log(note.classList.toggle('hint'), note.classList.length);
console.log(document.querySelector('#missing'));

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Reading list</title>
</head>
<body>
  <h1 id="title">My books</h1>
  <ul id="books"><li data-pages="412">Dune</li><li data-pages="474">Emma</li><li data-pages="96">Animal Farm</li></ul>
  <p id="note" class="hint">Loading...</p>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

3 undefined
[ 'Dune', 'Emma', 'Animal Farm' ]
[ 412, 474, 96 ]
My books (3)
yes true
<b>2</b> long books 0
2 long books 1
false 0
null
<!-- #books -->
<ul id="books"><li data-pages="412" class="long" data-read="yes">Dune</li><li data-pages="474" class="long">Emma</li><li data-pages="96" title="A short one">Animal Farm</li></ul>
  • typeof items.map is undefined: a NodeList has no map, so the program spreads it first.
  • dataset.pages is the string "412"; Number() turns it into a number for the comparison.
  • With textContent the note has no child elements; with innerHTML the <b> became one.
  • toggle removed hint and returned false, so the note has no classes left.
  • querySelector printed null for #missing: no error until you use the null.
  • The snapshot shows the classes and attributes the script added to the list.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

Tick off the finished tasks

The page lists four tasks, each with data-done="yes" or data-done="no". main.js should add the class done to every finished task, and to no other, and set the paragraph #summary to "2 of 4 done", counted from the page. The starter marks only the first item and writes a fixed summary. Select all the items, spread them into an array, and use dataset and classList.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: only the first task is marked, and the summary is fixed text.

  2. Hint 2

    [...document.querySelectorAll('#tasks li')] gives an array you can loop over and filter.

  3. Hint 3

    task.dataset.done is the string 'yes' or 'no'; count with filter(...).length and build the text from that number and tasks.length.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const tasks = [...document.querySelectorAll('#tasks li')];
for (const task of tasks) {
  if (task.dataset.done === 'yes') task.classList.add('done');
}

const done = tasks.filter((task) => task.dataset.done === 'yes').length;
document.querySelector('#summary').textContent = done + ' of ' + tasks.length + ' done';
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const task = document.querySelector('#tasks li');
task.classList.add('done');

// TODO: mark every finished task, then count them for the summary.
document.querySelector('#summary').textContent = '1 of 1 done';

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const marked = (page) => [...page.document.querySelectorAll('#tasks li')].map((li) => li.classList.contains('done'));

test('Every finished task has the class done', async () => {
  const page = await loadPage();
  const got = marked(page);
  assert.equal(got[0] && got[2], true, 'the class done is on these tasks: ' + JSON.stringify(got) + ', expected on the 1st and 3rd');
});

test('Unfinished tasks do not get the class done', async () => {
  const page = await loadPage();
  const got = marked(page);
  assert.deepEqual(got, [true, false, true, false], 'the class done is on these tasks: ' + JSON.stringify(got));
});

test('The summary says 2 of 4 done', async () => {
  const page = await loadPage();
  const text = page.document.querySelector('#summary').textContent;
  assert.equal(text, '2 of 4 done', 'the summary says ' + JSON.stringify(text));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Today</title>
</head>
<body>
  <h1>Today</h1>
  <ul id="tasks">
    <li data-done="yes">Buy milk</li>
    <li data-done="no">Call Ada</li>
    <li data-done="yes">Water the plants</li>
    <li data-done="no">Book tickets</li>
  </ul>
  <p id="summary"></p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 2

A profile card that shows text as text

The profile comes from a sign-up form, so every value is text a user typed, and the bio contains markup. Fill the card: #name and #bio show the values exactly, as text, with no elements made from them; the image #photo gets src set to the photo and alt "Photo of " plus the name; the card gets data-role with the role, and the class admin when the role is "admin", keeping its class card. The starter uses innerHTML and leaves the rest undone.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the bio shows "I love maths" in bold, because innerHTML made a <b> element from the text.

  2. Hint 2

    Swap innerHTML for textContent, then use photo.setAttribute('src', …) and photo.setAttribute('alt', …).

  3. Hint 3

    card.dataset.role = profile.role writes data-role; classList.add('admin') adds a class and keeps card.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Values a user typed into a sign-up form: treat them as text.
const profile = { name: 'Ada Lovelace', bio: 'I love <b>maths</b>', role: 'admin', photo: 'ada.png' };

const card = document.querySelector('#card');
document.querySelector('#name').textContent = profile.name;
document.querySelector('#bio').textContent = profile.bio;

const photo = document.querySelector('#photo');
photo.setAttribute('src', profile.photo);
photo.setAttribute('alt', 'Photo of ' + profile.name);

card.dataset.role = profile.role;
if (profile.role === 'admin') card.classList.add('admin');
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Values a user typed into a sign-up form: treat them as text.
const profile = { name: 'Ada Lovelace', bio: 'I love <b>maths</b>', role: 'admin', photo: 'ada.png' };

const card = document.querySelector('#card');
document.querySelector('#name').innerHTML = profile.name;
document.querySelector('#bio').innerHTML = profile.bio;
// TODO: the photo and its alt text, data-role, and the class admin.

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

test('The bio is shown as text, not as HTML', async () => {
  const page = await loadPage();
  const bio = page.document.querySelector('#bio');
  assert.equal(bio.children.length, 0, 'the bio holds ' + bio.children.length + ' element(s) made from the text, expected none');
  assert.equal(bio.textContent, 'I love <b>maths</b>', 'the bio shows ' + JSON.stringify(bio.textContent));
});

test('The name is shown', async () => {
  const page = await loadPage();
  const text = page.document.querySelector('#name').textContent;
  assert.equal(text, 'Ada Lovelace', 'the name shows ' + JSON.stringify(text));
});

test('The photo has its src and alt text', async () => {
  const page = await loadPage();
  const photo = page.document.querySelector('#photo');
  assert.equal(photo.getAttribute('src'), 'ada.png', 'src is ' + JSON.stringify(photo.getAttribute('src')));
  assert.equal(photo.getAttribute('alt'), 'Photo of Ada Lovelace', 'alt is ' + JSON.stringify(photo.getAttribute('alt')));
});

test('The card has data-role and the classes card and admin', async () => {
  const page = await loadPage();
  const card = page.document.querySelector('#card');
  assert.equal(card.getAttribute('data-role'), 'admin', 'data-role is ' + JSON.stringify(card.getAttribute('data-role')));
  assert.equal(card.classList.contains('admin'), true, 'the card has no class admin');
  assert.equal(card.classList.contains('card'), true, 'the card lost its class card');
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Profile</title>
</head>
<body>
  <article id="card" class="card">
    <img id="photo" src="blank.png" alt="">
    <h2 id="name">Name</h2>
    <p id="bio"></p>
  </article>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

A selector that matches nothing

const title = document.querySelector('#titel');
title.textContent = 'My tags';

What Node.js prints

TypeError: Cannot set properties of null (setting 'textContent')

Why, and the fix

The id is spelled #titel, and no element has it, so querySelector returned null. The error comes one line later, when the program sets a property on null. Compare the selector with index.html, character by character: here it should be #title. If an element may really be missing, check first: if (title) { … }. Firefox and Safari word this message differently.

Calling map on a NodeList

const tags = document.querySelectorAll('.tag');
const names = tags.map((li) => li.textContent);
console.log(names);

What Node.js prints

TypeError: tags.map is not a function

Why, and the fix

querySelectorAll returns a NodeList, which looks like an array but is not one: it has length, indexes and forEach, but no map, filter or reduce. Spread it into a real array first: [...tags].map((li) => li.textContent). A for...of loop over the NodeList works too.

Treating the NodeList as one element

const tags = document.querySelectorAll('.tag');
tags.classList.add('on');

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'add')

Why, and the fix

classList belongs to each element, not to the list of them, so tags.classList is undefined, and calling add on it fails. Change every element in a loop: for (const tag of tags) tag.classList.add('on'). If you meant only the first, use querySelector, which returns one element.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

The page is a tree, and selectors find its nodes

The browser turns the HTML into the DOM, a tree of nodes: document at the top, then html, head and body, and each element with its parent and children. document.querySelector(selector) takes a CSS selector and returns the first element that matches, or null. querySelectorAll returns a NodeList of every match, empty when none does, and it does not grow when elements are added later. A NodeList has length and forEach but no map or filter: [...list] makes an array. getElementById("title") takes the id without #. main.js is loaded as a module script, and modules are deferred: the whole HTML is parsed before it runs, so it needs no DOMContentLoaded listener.

textContent for text, innerHTML only for markup you wrote

el.textContent reads all the text inside an element; setting it replaces everything inside with one piece of text, shown exactly as written, so "<b>Hi</b>" appears as those characters. el.innerHTML reads the string as HTML and builds elements from it. That is fine for markup you wrote yourself, but text a user typed may contain markup, and innerHTML would put it into your page; this is how XSS attacks work, which module A5 covers. So text from users, forms or other sites always goes in with textContent. Browsers also have innerText, which depends on CSS layout; this course uses textContent.

Attributes, data-* values and classes

getAttribute("alt") returns an attribute's value as a string, or null when the element does not have it. setAttribute("alt", "A fox") changes it or adds it. Attributes that start with data- hold your own data: data-pages="412" is el.dataset.pages, and data-due-date becomes el.dataset.dueDate. dataset values are always strings, so convert with Number() before you do sums. classList changes one class and keeps the others: add, remove, contains, and toggle, which adds a missing class and removes a present one, and returns true when the class is now there. Styling by class keeps the look in CSS and the logic in JavaScript.

Sources

Last reviewed October 4, 2026