FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·071·8 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 allowing classes to become state.

Retrieval Warm-Up

Before continuing, retrieve a few ideas from the preceding lessons:

  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: A named hook in the class attribute that stylesheet rules can target. — Source: MDN: classList
  • classList: A live collection of class tokens that lets you add, remove, and toggle classes safely. — Source: MDN: Element.classList
  • Visual state: A condition visible to the user, such as open, active, or error, expressed through classes or styles. — Source: MDN: classList
  • Inline style: A per-element style property that overrides stylesheet rules; it should be used selectively. — Source: MDN: HTMLElement.style
  • Data attribute: A data-* attribute for custom data attached to an element and read through 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 instead of being maintained as a second copy of that 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

When a task becomes complete, JavaScript needs to communicate that fact. It does not need to paint every affected CSS property itself. Code like this mixes behavior with presentation:

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

A better boundary is for JavaScript to expose the condition and for CSS to decide how that condition looks:

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

The two-argument form, toggle(token, force), is particularly useful in render(). The class is present exactly when force is truthy, so rendering is deterministic. If you call the one-argument toggle() every time render() runs, the output alternates even when the underlying state has not changed.

Classes are output from the view; they are not the application's source of truth. When deciding what a task means, ask whether task.completed is true. Do not use item.classList.contains("is-complete") as the state that drives the next update.

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 previews the architecture introduced in 061:

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

render() synchronizes three outputs: the class on the root element, the button's pressed state, and the task list. aria-pressed identifies the button as a toggle button. Its visible and accessible name remains "Dark theme" while only its pressed state changes. If the label changed to the opposite action, the meaning of aria-pressed would become ambiguous. The attribute must be the string "true" or "false"; String(isDark) makes that conversion explicit.

Read the control as a named setting: "Dark theme, not pressed" in light mode and "Dark theme, pressed" in dark mode. Keeping the same noun phrase while the state changes gives visual and screen-reader users a stable model. A design that uses action labels such as "Switch to dark theme" and "Switch to light theme" should use an ordinary command button without aria-pressed. Do not combine opposite-action labels with toggle-button state.

Completion is not communicated by color alone. The title is crossed out, and visible text says "complete." The final application would also communicate the state through a checkbox.

classList Operations

These are the core operations you will use:

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 works with individual tokens, so you do not have to manage whitespace or concatenate class strings by hand. Avoid this pattern:

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

It can duplicate tokens and can overwrite or corrupt classes that other code has assigned. Assigning className is reasonable when you deliberately intend to replace the entire class string; it is not a good default for adding one state.

When Inline Styles Fit

Classes are the right fit for named states and reusable design rules. The style property is more appropriate for a value 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}`;

In this case, a native <progress> element expresses the meaning more clearly than a style.width value. If a chart genuinely needs a calculated custom property, set that property directly:

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

Avoid putting colors inline. Themes, forced-colors mode, hover and focus states, and media queries are all easier to manage in CSS. Also, never remove a focus outline unless you provide an equally visible replacement.

Data Attributes: Identity, Not a Database

When you write item.dataset.taskId = task.id, the element receives data-task-id="1". An event handler on a descendant can later find the closest task item and retrieve that identifier. IDs, actions, and simple categories are good candidates for dataset values.

Every dataset value is a string:

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

Do not use attributes for secrets, trusted flags, or large JSON objects. Users can see and edit attributes in DevTools. Keep application state in JavaScript and, later, in storage that you validate when reading it.

Intermediate Example: Three-Way Priority

For a small, fixed set of priority values, derive each class explicitly from state instead of constructing a class name 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 becoming an arbitrary class. Pair the visual treatment with text so priority is not conveyed through styling alone:

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

Optional Advanced Example: Respect System Theme

The operating system's preference can choose the initial theme, while the explicit button still controls the application afterward:

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

Do not silently overwrite the user's chosen theme whenever the media query changes unless that behavior is intentional and explained. A better later model is theme: "system" | "light" | "dark"; only the system mode should follow subsequent preference changes.

Mistakes and Debugging

  • Toggling on every render: classList.toggle("active") is not deterministic. Pass the desired condition as the second argument.
  • Reading state from classes: The two representations can drift. Read state.tasks, derive classes from it, and render.
  • Using only color: Add text, native control state, shape, or another independent cue.
  • Setting aria-pressed once: Accessibility state must be updated whenever the 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 actually required.
  • Overusing inline styles: Inspect the element's Styles panel and move stable rules into CSS classes.
  • Building classes from user input: Restrict dynamic classes to an allowlist of known state values.

For debugging, start by logging the JavaScript state. Then inspect the element's classList, computed styles, and accessibility properties in DevTools. If the class is correct but the appearance is wrong, inspect CSS specificity and rule order. If the class is wrong, inspect the state and the condition that render() uses to derive it.

Accessibility, Security, and Performance

Accessibility: Native controls provide semantics and keyboard behavior. A toggle button needs an accessible name and a synchronized aria-pressed value. Keep focus visible. Normal text should meet WCAG AA contrast, generally 4.5:1, and meaningful control boundaries and focus indicators need sufficient non-text contrast. Test both themes, zoom, keyboard operation, and forced colors. Never communicate completion or priority through color alone.

Security: Users and browser extensions can edit classes and data attributes, so neither can authorize an action. Never treat data-admin="true" as proof of permission. Restrict dynamic class names to known values, and continue inserting task titles with textContent.

Performance: Changing a class can trigger style recalculation, but class changes are still the appropriate abstraction for visual state. Batch related changes in render(). Avoid alternating layout reads such as offsetWidth with style writes inside loops, because that can force repeated layout. Applying one theme class to the root is generally preferable to 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 the option is 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.
  • Use data attributes for simple rendered metadata, usually an ID or action.
  • Synchronize visible, native, and ARIA states without relying on color alone.

Official References

Reader page: /javascript/lesson/071/classes-styles-attributes-and-dataset