Selecting and Changing Elements

querySelector, classList, dataset and the methods that actually get used. The small set of DOM operations that covers most day to day work.

Selecting

document.querySelector(".card");        // the first match, or null
document.querySelectorAll(".card");     // a static NodeList of all matches

document.getElementById("chart");       // by id, fastest
document.getElementsByClassName("card");// a live HTMLCollection
document.getElementsByTagName("p");     // a live HTMLCollection

In practice querySelector and querySelectorAll cover almost everything: any CSS selector works, and the API is consistent.

Static versus live

const staticList = document.querySelectorAll(".card");   // a snapshot
const liveList   = document.getElementsByClassName("card"); // updates itself

document.body.append(newCard);

staticList.length;   // unchanged
liveList.length;     // one more

A live collection updating while you iterate over it is a classic infinite loop. Prefer the static querySelectorAll.

Scoped selection

const card = document.querySelector(".card");
const title = card.querySelector("h3");   // searches inside card only

Always scope a search to the smallest container that makes sense. It is faster and it stops a selector accidentally matching something elsewhere on the page.

Iterating

document.querySelectorAll(".card").forEach((card) => {
  card.classList.add("is-ready");
});

// a NodeList is not an array; convert when array methods are needed
const titles = [...document.querySelectorAll("h2")].map((h) => h.textContent);

Changing content

el.textContent = "Design course";           // safe, fast
el.innerHTML = "<strong>Design</strong>";   // parses markup - only with trusted values

Changing classes

el.classList.add("is-open");
el.classList.remove("is-open");
el.classList.toggle("is-open");
el.classList.toggle("is-open", shouldBeOpen);   // force a state
el.classList.contains("is-open");
el.classList.replace("is-loading", "is-ready");

Never assign to className to add one class - it replaces every class on the element.

Changing attributes

link.href = "/courses/design";
img.src = "/images/design.jpg";
img.alt = "Students at drawing boards";
input.value = "Meera";
input.disabled = true;
button.setAttribute("aria-expanded", "true");
el.removeAttribute("hidden");
el.hidden = true;                    // the property form
el.dataset.status = "paid";

Common attributes have direct properties. ARIA attributes generally do not, so setAttribute is used for those.

Changing styles

// avoid: hard to override, mixes design into script
el.style.backgroundColor = "#1e3a8a";

// prefer: the stylesheet keeps the design
el.classList.add("is-highlighted");

// for genuinely dynamic values, a custom property
el.style.setProperty("--progress", "68%");

Note that el.style reads only inline styles. To read a computed value:

getComputedStyle(el).backgroundColor;

Showing and hiding

el.hidden = true;                    // simplest, removes it from the accessibility tree
el.style.display = "none";
el.classList.add("is-hidden");
MethodVisibleIn the tab orderAnnouncedTakes space
hidden / display: noneNoNoNoNo
visibility: hiddenNoNoNoYes
opacity: 0NoYesYesYes
.visually-hiddenNoYesYesNo
aria-hidden="true"YesYesNoYes

Two rows are traps. opacity: 0 leaves the element fully focusable, so a keyboard user tabs into something invisible. And aria-hidden="true" on a focusable element creates a control that can be reached but not announced - the worst of both.

.visually-hidden is the deliberate case: hidden from sight, present for screen readers. That is how skip links and off screen labels work.

Building elements

const card = document.createElement("article");
card.className = "card";
card.dataset.id = course.id;

const title = document.createElement("h3");
title.textContent = course.title;

const link = document.createElement("a");
link.href = `/courses/${encodeURIComponent(course.slug)}`;
link.textContent = "Read more";

card.append(title, link);        // several at once
container.append(card);

For anything more complex than this, use a template element instead of building node by node.

A worked example

<div class="filters">
  <button type="button" data-filter="all" aria-pressed="true">All</button>
  <button type="button" data-filter="design" aria-pressed="false">Design</button>
</div>

<ul id="courses">
  <li data-category="design">Product design</li>
  <li data-category="data">Analytics</li>
</ul>

<p id="count" aria-live="polite"></p>
const filters = document.querySelector(".filters");
const items   = [...document.querySelectorAll("#courses li")];
const count   = document.getElementById("count");

function applyFilter(value) {
  let shown = 0;

  items.forEach((item) => {
    const show = value === "all" || item.dataset.category === value;
    item.hidden = !show;
    if (show) shown += 1;
  });

  filters.querySelectorAll("button").forEach((button) => {
    button.setAttribute("aria-pressed", String(button.dataset.filter === value));
  });

  count.textContent = `Showing ${shown} of ${items.length} courses`;
}

filters.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-filter]");
  if (button) applyFilter(button.dataset.filter);
});

Three details worth copying: hidden rather than a CSS class, so filtered items leave the tab order; aria-pressed kept accurate so the active filter is announced; and a live region so the result count is spoken without moving focus.

Important rules

  • querySelector returns null when nothing matches. Check before using it.
  • querySelectorAll returns a static list; getElementsBy* return live ones.
  • A NodeList has forEach but not map or filter. Spread it first.
  • Assigning to className replaces every class.
  • el.style reads inline styles only.
  • Selecting is cheap; changing layout is not.

Common mistakes

  • Not checking for null and getting an error on a page where the element does not exist.
  • Using className to add a class.
  • Modifying a live collection while iterating it.
  • Setting styles from script instead of toggling a class.
  • Hiding with opacity: 0 and leaving focusable elements behind.
  • Querying the whole document when a container would do.
  • Building large structures with innerHTML strings.

Best practices

  • querySelector and querySelectorAll for nearly everything.
  • Scope queries to a container.
  • Cache selections rather than querying repeatedly.
  • Toggle classes and data attributes; keep styles in CSS.
  • Use hidden to hide things properly.
  • Keep ARIA state attributes in step with what the reader sees.
  • Use template for repeated structures.

Practice

  1. Build a filter that hides items with hidden and keeps aria-pressed accurate.
  2. Hide an element four different ways and tab through each. Which ones can you still reach?
  3. Take a live getElementsByClassName collection, add a matching element, and observe the length change.
  4. Replace a chain of el.style assignments with a single class toggle.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →
HTML

Forms and JavaScript

Read values, submit without a page reload, validate with the built in API, and handle errors accessibly. All of it built on a real form element.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.