Module: JavaScript
JavaScript·071·6 MIN READ

071: Classes, Styles, Attributes, and Dataset

TOPICS COVERED: Classes, Styles, Attributes, and Dataset

Outcomes

By the end of this lesson, you can:

  • use classList.add(), remove(), toggle(), and contains();
  • explain why CSS classes should express visual state;
  • use inline style only for genuinely calculated one-off values;
  • read and write data-* values through dataset;
  • make a theme and task-status view accessible; and
  • render classes from state rather than letting classes become state.

Retrieval Warm-Up

  1. Why is textContent safer than parsing a task title as HTML?
  2. What three steps describe creating a DOM element?
  3. Why should deleting a task update the array before the DOM?

Terms

  • CSS class: Named hook inside the class attribute targeted by stylesheet rules. — Source: MDN: classList
  • classList: Live token collection adding/removing/toggling classes safely. — Source: MDN: Element.classList
  • Visual state: User-facing condition (open, active, error) expressed via classes/styles. — Source: MDN: classList
  • Inline style: Per-element style property overriding stylesheet rules; use sparingly. — Source: MDN: HTMLElement.style
  • Data attribute: data-* attributes storing custom data on elements, read via dataset. — Source: WHATWG HTML: Custom data attributes
  • Separation of concerns: Keeping structure (HTML), presentation (CSS), and behavior (JS) distinct. — Source: MDN: Structuring documents
  • Derived view: Rendered output computed from stored state rather than duplicated state. — Source: MDN: Glossary — MVC
  • Separation of concerns (official): "Separation of concerns is the principle of keeping distinct functions (structure, presentation, behavior) in distinct layers." — Source: MDN: Structuring documents
  • Derived view (official): "A derived view is data computed from source state, not stored separately." — Source: MDN: Glossary — MVC

Mental Model: JavaScript Flips Meaningful Switches

Avoid making JavaScript paint every CSS property:

js
// Fragile presentation logic:
item.style.color = "gray";
item.style.textDecoration = "line-through";

Instead, JavaScript communicates a condition and CSS decides its appearance:

js
item.classList.toggle("is-complete", task.completed);
css
.task.is-complete .task-title {
  color: #555;
  text-decoration: line-through;
}

The two-argument form of toggle(token, force) is especially useful in render(): the class is present exactly when force is truthy. This is deterministic. Calling one-argument toggle() during every render would alternate output even if state had not changed.

Classes are view output, not application state. Ask task.completed, not item.classList.contains("is-complete"), when deciding what the task means.

Self-Study Example: Theme and Status Rendering

Create this complete index.html:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Todo visual states</title>
    <link rel="stylesheet" href="styles.css">
    <script src="app.js" defer></script>
  </head>
  <body>
    <header>
      <h1>My tasks</h1>
      <button id="theme-button" type="button" aria-pressed="false">
        Dark theme
      </button>
    </header>
    <main>
      <p id="summary" role="status"></p>
      <ul id="task-list"></ul>
    </main>
  </body>
</html>

Add styles.css:

css
:root {
  color-scheme: light;
  font-family: system-ui, sans-serif;
  background: #fff;
  color: #17202a;
}

:root.theme-dark {
  color-scheme: dark;
  background: #17202a;
  color: #f7f9f9;
}

body {
  max-width: 42rem;
  margin: auto;
  padding: 1rem;
}

button,
input {
  font: inherit;
}

button:focus-visible,
input:focus-visible {
  outline: 3px solid #8e44ad;
  outline-offset: 3px;
}

.task {
  margin-block: 0.75rem;
}

.task.is-complete .task-title {
  color: #566573;
  text-decoration: line-through;
  text-decoration-thickness: 0.12em;
}

:root.theme-dark .task.is-complete .task-title {
  color: #d5d8dc;
}

.priority-high {
  border-inline-start: 0.35rem solid #a93226;
  padding-inline-start: 0.5rem;
}

Add app.js:

js
const state = {
  theme: "light",
  tasks: [
    { id: "1", title: "Learn classList", completed: false, priority: "high" },
    { id: "2", title: "Separate CSS and JS", completed: true, priority: "normal" },
  ],
};

const root = document.documentElement;
const themeButton = document.querySelector("#theme-button");
const taskList = document.querySelector("#task-list");
const summary = document.querySelector("#summary");

function createTaskItem(task) {
  const item = document.createElement("li");
  item.classList.add("task");
  item.classList.toggle("is-complete", task.completed);
  item.classList.toggle("priority-high", task.priority === "high");
  item.dataset.taskId = task.id;

  const title = document.createElement("span");
  title.classList.add("task-title");
  title.textContent = task.title;

  const status = document.createElement("span");
  status.textContent = task.completed ? " (complete)" : " (not complete)";

  item.append(title, status);
  return item;
}

function render() {
  const isDark = state.theme === "dark";
  root.classList.toggle("theme-dark", isDark);
  themeButton.setAttribute("aria-pressed", String(isDark));

  taskList.replaceChildren(...state.tasks.map(createTaskItem));
  const completeCount = state.tasks.filter((task) => task.completed).length;
  summary.textContent = `${completeCount} of ${state.tasks.length} complete.`;
}

themeButton.addEventListener("click", () => {
  state.theme = state.theme === "light" ? "dark" : "light";
  render();
});

render();

The click listener is a preview of 061. Follow its architecture:

text
state -> render -> click -> update state.theme -> render

The render operation synchronizes the root class, button pressed state, and task list. aria-pressed makes the button a toggle button. Its visible and accessible name stays "Dark theme" while only its pressed state changes; changing the name to the opposite action would make the meaning of aria-pressed ambiguous. The attribute must be the string "true" or "false"; String(isDark) makes that explicit.

Read the control as a named setting: "Dark theme, not pressed" in light mode and "Dark theme, pressed" in dark mode. The same noun phrase and changing state form a stable mental model for visual and screen-reader users. If a design instead uses action labels such as "Switch to dark theme" and "Switch to light theme," it should be an ordinary command button without aria-pressed; do not combine opposite-action labels with toggle-button state.

The completed appearance is not conveyed by color alone. The title has a line, and visible text says "complete." The state would also be communicated by a checkbox in the final app.

classList Operations

js
item.classList.add("task", "priority-high");
item.classList.remove("priority-high");
item.classList.toggle("is-complete");
item.classList.toggle("is-complete", task.completed);
item.classList.contains("is-complete");

classList handles tokens without fragile string concatenation. Avoid:

js
item.className += " is-complete";

That can duplicate tokens and overwrite or corrupt existing classes. Assigning className is reasonable only when intentionally replacing the complete class string.

When Inline Styles Fit

Classes suit named states and reusable design. The style property fits values calculated uniquely at runtime, such as progress:

js
const progress = document.createElement("progress");
progress.max = state.tasks.length;
progress.value = state.tasks.filter((task) => task.completed).length;
progress.textContent = `${progress.value} of ${progress.max}`;

Here a native <progress> is more semantic than style.width. If a chart genuinely needs a calculated custom property, use:

js
root.style.setProperty("--completion", `${progress.value / progress.max}`);

Avoid inline colors because themes, forced-colors mode, hover, focus, and media queries are easier in CSS. Never remove focus outlines without supplying an equally visible replacement.

Data Attributes: Identity, Not a Database

item.dataset.taskId = task.id renders data-task-id="1". Later, an event on a descendant can locate its closest task item and retrieve that ID. Useful values include IDs, actions, and simple categories.

All dataset values are strings:

js
item.dataset.position = 3;
console.log(typeof item.dataset.position); // "string"

Do not store secrets, trusted flags, or large JSON objects in attributes. Attributes are visible and editable in DevTools. State should remain in JavaScript and, later, validated storage.

Intermediate Example: Three-Way Priority

For a small fixed set, derive classes from state without constructing a class from arbitrary input:

js
function applyPriority(item, priority) {
  item.classList.toggle("priority-low", priority === "low");
  item.classList.toggle("priority-normal", priority === "normal");
  item.classList.toggle("priority-high", priority === "high");
}

This allowlist prevents an unexpected value from adding arbitrary classes. Combine visuals with text:

js
const priorityText = document.createElement("span");
priorityText.textContent = `Priority: ${task.priority}`;
item.append(" ", priorityText);

Optional Advanced Example: Respect System Theme

The system preference can provide the initial state while the explicit button remains under user control:

js
const prefersDark = matchMedia("(prefers-color-scheme: dark)");
state.theme = prefersDark.matches ? "dark" : "light";
render();

Do not automatically overwrite the user's chosen theme whenever the media query changes unless that behavior is explained. A better later model is theme: "system" | "light" | "dark", where only system mode follows changes.

Mistakes and Debugging

  • Toggling on every render: classList.toggle("active") is not deterministic. Use the second argument.
  • Reading state from classes: updates can drift. Read state.tasks, derive classes, and render.
  • Using only color: include text, native control state, shape, or another cue.
  • Setting aria-pressed once: accessibility state must update whenever visual state changes.
  • Writing element.dataset.task-id: hyphens are not property syntax. data-task-id maps to dataset.taskId.
  • Assuming dataset types: convert with Number() only after validation when a number is needed.
  • Overusing inline styles: inspect the element's Styles panel and move stable rules into CSS classes.
  • Building classes from user input: use an allowlist of known state values.

Debug by logging state and then checking element.classList, computed styles, and accessibility properties in DevTools. If the class is right but appearance is wrong, inspect CSS specificity and rule order. If the class is wrong, inspect state and render conditions.

Accessibility, Security, and Performance

Accessibility: native controls provide semantics and keyboard behavior. A toggle button needs an accessible name and synchronized aria-pressed. Maintain visible focus. Ensure normal text reaches WCAG AA contrast (generally 4.5:1), and meaningful control boundaries/focus indicators have sufficient non-text contrast. Test light and dark themes, zoom, keyboard operation, and forced colors. Do not communicate completion or priority only through color.

Security: classes and data attributes can be edited by users and extensions, so they cannot authorize actions. Never treat data-admin="true" as proof. Restrict dynamic class names to known values and continue inserting task titles with textContent.

Performance: changing a class can cause style recalculation, but it is the correct abstraction. Batch related changes in render() and avoid alternately reading layout (offsetWidth) and writing styles in loops, which can force repeated layout. Theme one root class instead of updating every descendant.

Exercises

Core

Add a show-completed class to root only when at least one task is complete. Check it with contains() in the console.

Practice

Add priority-low and priority-normal rules and use applyPriority() for every task. Include visible priority text.

Professional Extension

Add a reduceMotion boolean to state, a toggle button with aria-pressed, and a root class. Write CSS that disables transitions when active.

Core

js
const hasCompleted = state.tasks.some((task) => task.completed);
root.classList.toggle("show-completed", hasCompleted);
console.log(root.classList.contains("show-completed"));

Place the first two lines inside render().

Practice

css
.priority-low { border-inline-start: 0.35rem solid #2874a6; padding-inline-start: 0.5rem; }
.priority-normal { border-inline-start: 0.35rem solid #626567; padding-inline-start: 0.5rem; }
js
applyPriority(item, task.priority);
const priorityText = document.createElement("span");
priorityText.textContent = ` Priority: ${task.priority}.`;
item.append(title, status, priorityText);

Professional Extension

html
<button id="motion-button" type="button" aria-pressed="false">
  Reduce motion
</button>
css
:root.reduce-motion *,
:root.reduce-motion *::before,
:root.reduce-motion *::after {
  scroll-behavior: auto;
  transition-duration: 0.01ms;
}
js
state.reduceMotion = false;
const motionButton = document.querySelector("#motion-button");

motionButton.addEventListener("click", () => {
  state.reduceMotion = !state.reduceMotion;
  render();
});

// Inside render():
root.classList.toggle("reduce-motion", state.reduceMotion);
motionButton.setAttribute("aria-pressed", String(state.reduceMotion));

Keep the visible label "Reduce motion" stable. The changing aria-pressed value communicates whether that named option is on or off.

Recap

  • Use classes for meaningful, reusable visual states.
  • Use toggle(className, condition) for deterministic rendering.
  • Keep meaning in state and derive classes from it.
  • Use inline styles sparingly for calculated values.
  • Data attributes carry simple rendered metadata, usually an ID or action.
  • Synchronize visible, native, and ARIA states without relying on color alone.

Official References