Manipulating CSS Classes with JavaScript Using the classList API
Use the classList API to add, remove, toggle, and replace CSS classes, and know when a data-* attribute or custom property is the better choice for component state that JavaScript needs to read.
Most developers reach for JavaScript classList to manipulate CSS classes the moment a node needs to change appearance. That is often the wrong first move. The classList API is not a state manager; it is a styling tool. When you use it to encode application state, “is this panel open”, “has this field errored”, you force the CSS cascade to act as a memory system. You pay for it in style recalculation and in code that cannot read its own state back without parsing the DOM. Use classList for what the browser optimized it for: flipping a styling hook. Use data-* attributes or CSS custom properties for state that JavaScript or CSS needs to inspect.
The classList API: A Complete Reference
Element.classList is a DOMTokenList, a live collection of the element’s CSS classes as individual tokens. It is defined by the DOM Standard (WHATWG Living Standard), which means it works in every browser that implements the DOM, essentially every browser released since 2015. The classList object exposes five methods that cover nearly every class mutation you will ever need.
Add, Remove, And The One Mistake Everyone Makes
classList.add(token1, token2, tokenN) appends one or more tokens to the class attribute, ignoring duplicates. classList.remove(token1, token2, tokenN) deletes them. Both take separate arguments; passing a single string with spaces like "foo bar" is the most common mistake and it fails silently. If you need to add or remove a list of classes from a variable, spread it: element.classList.add(...classesArray).
Toggle And Its Second Argument
classList.toggle(token) adds the token if it is absent and removes it if present, returning a boolean: true if the class is now present, false if removed. The second argument force is a boolean: toggle(token, true) always adds, toggle(token, false) always removes. This is the cleanest way to implement a switch whose state you want to keep on the element, but the return value only tells you about the class, not about the conceptual state it represents.
Replace, Contains, And The Legacy ClassName
classList.replace(oldToken, newToken) swaps one class for another, returning true on success and false if the old token did not exist. This is the method for a transition where an element moves from one state class to another without ever holding both. classList.contains(token) returns a boolean; it is the only way to ask the DOM “does this element carry this styling hook right now?”, and the answer is only about the class, not about why it is there.
Before classList, developers manipulated the className property: a single string containing all classes separated by whitespace. Setting element.className = "foo bar" replaces the entire class attribute, and reading it returns that string. The failure mode is the reason classList exists: splitting, joining, and regex matching on a string that can be reordered, duplicated, or contain accidental whitespace. If you are supporting a system that predates 2015, you can feature-detect with if (element.classList) and fall back to className string logic. For any new code, classList is the only correct choice.
Which Method When
Use add and remove for mutually exclusive styling that does not need to be read back. Use toggle for a boolean styling hook like a dark-mode flag. Use replace when an element must transition between two classes and you need to guarantee only one is present at any instant. Use contains only for a quick style check, never for state logic. That is what the next sections explain.
A Real Dark-Mode Toggle with classList.toggle
Here is a complete, runnable dark-mode switch. The pattern is the canonical use of classList: a single class on the root element that the entire page’s CSS reads. The button toggles the class, and the CSS cascade applies the theme.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dark mode with classList.toggle</title>
<style>
:root {
--bg: #ffffff;
--text: #1a1a1a;
--accent: #0066cc;
}
body.dark {
--bg: #1a1a1a;
--text: #f0f0f0;
--accent: #66b3ff;
}
body {
background: var(--bg);
color: var(--text);
transition: background 0.3s, color 0.3s;
}
button {
padding: 0.5rem 1rem;
border: 2px solid var(--accent);
background: transparent;
color: var(--text);
cursor: pointer;
}
</style>
</head>
<body>
<button id="theme-toggle">Toggle dark mode</button>
<p>This page uses classList.toggle on the body element.</p>
<script>
const toggle = document.getElementById('theme-toggle');
const body = document.body;
toggle.addEventListener('click', () => {
body.classList.toggle('dark');
});
</script>
</body>
</html>
Notice what this code does not do: it never reads back the class to decide anything. The only consumer of the dark class is the CSS selector body.dark. The button does not need to know whether dark mode is on; it flips the hook. This is the pattern classList was built for, and it works because the styling decision lives entirely in the cascade.
The trade-off appears the moment you need to know the state elsewhere. Suppose a second button must mirror the dark-mode state, or a script must report to an analytics system whether dark mode is active. You would call body.classList.contains('dark'), and now you are parsing the DOM to recover information that your application logic put there. That is the smell.
The Cost You Pay: Style Recalculation and Descendants
Every classList mutation, add, remove, toggle, replace, triggers a style recalculation on that element and, critically, on all of its descendants. The browser must re-evaluate every CSS rule that could match the new class, recompute the cascade, and then propagate the computed values down the DOM tree. The cost is not proportional to the number of classes you change; it is proportional to the size of the subtree under the element you mutate.
Toggling a class on the <body> element when the page has a large DOM, thousands of nodes, means the browser re-resolves the style of every single one. Toggling a class on a single <li> inside a long list still forces recalculation of that <li> and its children, but not its siblings, unless the CSS selector uses :has() or a descendant combinator that reaches upward, which inverts the cost model.
This is why “style recalculation” is not abstract. On a low-power mobile device, a careless classList.add on a high-DOM-depth ancestor, a wrapper around an entire grid, can cause a visible frame drop during scroll. The fix is not micro-optimizing the class name; it is choosing the right element to mutate. Put styling hooks as low in the DOM as possible: a class on the specific component’s root, not on a distant ancestor. If the state belongs to a single component, that component’s root element is the correct target.
Multi-Class Transitions with classList.replace
When an element moves between two mutually exclusive visual states, “loading” to “loaded”, “closed” to “open”, the temptation is to add one class and remove the other in two lines. classList.replace does it in one atomic operation, ensuring the element never holds both classes even momentarily. A brief window with both classes can trigger a rule that matches the union of the two states, producing a flash of the wrong styling.
Here is a complete example: a card that transitions from a pending state to a confirmed state. The transition uses a new class for the target state, and replace swaps it in with a single style recalculation event rather than two.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Transition with classList.replace</title>
<style>
.card {
padding: 1rem;
border: 1px solid #ccc;
transition: border-color 0.4s, background-color 0.4s;
}
.card.pending {
border-color: #999;
background: #f5f5f5;
}
.card.confirmed {
border-color: #2a7f2a;
background: #e8f5e8;
}
</style>
</head>
<body>
<div id="card" class="card pending">
<p>This is a pending item.</p>
</div>
<button id="confirm-btn">Confirm</button>
<script>
const card = document.getElementById('card');
const btn = document.getElementById('confirm-btn');
btn.addEventListener('click', () => {
const replaced = card.classList.replace('pending', 'confirmed');
if (!replaced) {
console.log('pending class was not present; card may already be confirmed');
}
});
</script>
</body>
</html>
The replace method returns a boolean so you can branch on whether the old class existed. That is useful for idempotency: if the user clicks confirm twice, the second call returns false because pending is gone, and you can choose not to re-trigger the transition. This is a legitimate use of the return value; it tells you about the class, and the class is the thing you are acting on.
className vs classList Performance: What the Benchmarks Actually Say
A common question is whether className assignment is faster than classList methods. The measured difference is negligible in modern engines for any realistic DOM size. Both end in the same place: the class attribute is updated, and the style system recalculates. The real performance gap is not between className and classList; it is between mutating a class on a high-DOM-depth ancestor versus a leaf node, and between triggering recalculation once versus multiple times.
Using element.className = "foo bar" replaces the entire attribute and forces the style system to diff the full token list. classList.add('foo') appends one token and lets the browser track the addition more narrowly. In practice, the difference is a few microseconds per call, invisible unless you are doing thousands of mutations per frame, which is a design problem, not a method problem.
The convention is clear: use classList for all new code because it is expressive, safe, and avoids the string-splitting bugs. className remains the correct fallback for code that must run in environments without classList, which means any browser released before 2015. If you are supporting that, the performance question is moot; the only sane choice is className, and you write your own tokenization logic carefully.
When Classes Are the Wrong Tool: State That Needs Reading Back
The breaking point is when JavaScript must know the state without inspecting the DOM. Consider a shopping cart: the cart icon shows a count, and the count is derived from an array in memory. If you store the cart state as classes on the icon, cart-empty, cart-has-items, cart-full, you cannot know the count without parsing the class attribute and mapping tokens back to numbers. That is not just awkward; it is a category error. The class is a symptom, not the cause.
Where State Belongs
A data-* attribute is the correct home for state that is read back. element.dataset.state = "open" or element.setAttribute('data-state', 'closed') stores a value that is queryable via CSS attribute selectors and via JavaScript’s dataset property. The DOM becomes a single source of truth that both languages can read. CSS can style based on the attribute: [data-state="open"] { display: block; }. JavaScript can read it: element.dataset.state === "open". The class remains available for pure styling hooks that do not carry semantic meaning.
The line is not about what the element looks like; it is about what the element means. A class named active is a styling hook. An attribute data-state="active" is a semantic state. If a designer wants to change what active looks like, they change CSS. If a developer wants to know whether the element is active, they read dataset.state. The two concerns stay separate, and neither leaks into the other.
CSS Class vs data-* Attribute vs CSS Custom Property: A Decision Guide
The confusion between these three mechanisms is the source of most over-engineered classList usage. Here is the clear distinction, and a table to keep it straight.
- CSS class: a token that participates in the cascade with specificity (0,1,0) per class. It is for styling hooks, naming a visual variant. It is not for storing values that need to be read back.
- data-* attribute: a custom attribute with a string value. It is invisible to CSS styling unless you write attribute selectors, but it is the standard way to store state that JavaScript reads. It does not affect specificity unless you use it in a selector.
- CSS custom property: a variable that cascades and inherits. It stores a value that CSS uses in
var(), a color, a dimension, a flag. JavaScript can read and write it viagetPropertyValueandsetProperty, but it is not designed for state that needs semantic meaning; it is for computed style tokens.
| Mechanism | What it is for | What it is not for | How JavaScript reads it | How CSS reads it |
|---|---|---|---|---|
| CSS class | Styling hook, variant name | State that needs reading back | classList.contains, classList.value | .class, specificity (0,1,0) |
| data-* attribute | State storage, semantic value | Styling (it works, but it is a misuse) | dataset.property | [data-name=”value”] selector |
| CSS custom property | Computed style token, theme value | State that changes behavior | getComputedStyle().getPropertyValue | var(–name) |
The Rule Of Thumb
If you would write a JavaScript if statement that checks the element’s state, use a data- attribute. If you would write a CSS rule that changes appearance based on a flag, use a class, and set that class from a data- attribute in JavaScript. If you need to pass a value to CSS for computation, a spacing scale, a color, use a custom property. This keeps the cascade, the DOM, and the style engine each doing the job they were built for.
Refactoring the Same Component: From Classes to data-state
Here is the same transition component from earlier, but refactored to the correct pattern. The state lives in data-state on the element root. JavaScript reads and writes that attribute. CSS uses an attribute selector to apply styling. A class is used only for the transition hook, a styling detail, not a state flag.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Component state with data-state attribute</title>
<style>
.card {
padding: 1rem;
border: 1px solid #ccc;
transition: border-color 0.4s, background-color 0.4s;
}
.card[data-state="pending"] {
border-color: #999;
background: #f5f5f5;
}
.card[data-state="confirmed"] {
border-color: #2a7f2a;
background: #e8f5e8;
}
/* This class is only a transition hook; it does not carry state. */
.card.is-transitioning {
animation: pulse 0.3s ease;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
</style>
</head>
<body>
<div id="card" class="card" data-state="pending">
<p>This is a pending item.</p>
</div>
<button id="confirm-btn">Confirm</button>
<script>
const card = document.getElementById('card');
const btn = document.getElementById('confirm-btn');
btn.addEventListener('click', () => {
if (card.dataset.state === 'confirmed') {
return; // already confirmed
}
card.dataset.state = 'confirmed';
// Add a temporary styling hook for the animation, remove after it ends.
card.classList.add('is-transitioning');
card.addEventListener('animationend', () => {
card.classList.remove('is-transitioning');
}, { once: true });
});
</script>
</body>
</html>
Now the JavaScript does not need to parse classes to know the state. card.dataset.state is a string that directly answers “is this confirmed?”, no contains call, no string splitting. The CSS attribute selector [data-state="confirmed"] has the same specificity as a class, (0,1,0), so it competes in the cascade on equal footing. The transition hook is-transitioning is a pure styling tool: it has no meaning beyond “play this animation now”, and it is removed when the animation ends. If you need to know why the element is animating, you read data-state, not the class.
This refactor scales. When a component has five states, you write [data-state="state1"], [data-state="state2"] and so on, and the JavaScript does not grow. With classes, you would need contains checks for each state or a switch on the class attribute; both are slower and more error-prone.
The Specificity Trap and BEM: Why Classes Are Not for State
A class selector has specificity (0,1,0). An attribute selector [data-state="confirmed"] also has (0,1,0). That parity is intentional: neither should dominate the other in the cascade. The moment you use a class to encode state, you invite a specificity war. Suppose you have a .is-active class that means “this tab is selected”. Then you want to style a selected tab inside a particular container: .container .is-active jumps to (0,2,0) and beats any single-class rule. Now you need a more specific selector for the unselected state, and the cascade grows into a tangle of increasingly specific overrides.
Where BEM Fits
The BEM naming convention exists to manage this: .block__element--modifier flattens specificity by keeping everything at (0,1,0). BEM is a valid discipline, but it solves a naming problem, not a state problem. BEM says “call your modifier class a modifier”; it does not tell you where to store the fact that the modifier is active. The data-* attribute is that storage. With BEM plus data-state, the markup is .block__element for the structure, data-state="variant" for the semantic condition, and the CSS uses [data-state="variant"] as the hook.
This is also where the modern @scope at-rule can replace BEM: @scope (.block) { .element { ... } } limits selector reach to the subtree, so specificity stays low and the state attribute does not need naming gymnastics. But @scope is about containment, not about state storage. The attribute remains the right home for the state itself.
The Performance Reality: Style Recalculation Is the Real Cost
The earlier claim that classList operations trigger style recalculation on descendants deserves a concrete look. On a page with a large DOM, toggling a class on the <body> element means the browser must re-resolve the cascade for every node in the tree, and that is just the style phase. If any of those nodes have CSS that changes layout (width, margin, display), the layout phase runs next, and then paint. A single class toggle on an ancestor can cascade into a full page reflow.
The contrast with data- attributes is sharp. Setting element.dataset.state = 'confirmed' does not automatically trigger style recalculation unless a CSS rule uses [data-state] in a selector that matches. If no rule matches, the attribute change is invisible to the style system; it is a DOM property update, O(1). If a rule matches, the style recalculation is scoped to the elements that match that selector, which is the element itself, not its entire subtree. This is why data- attributes are cheaper for state: they do not produce a cascade change unless a selector explicitly wants it.
For pure styling hooks, classList is still the right tool because the class is the trigger for the cascade. The rule: use a class when you want a recalculation; use an attribute when you do not. If the state does not affect styling, the attribute is free. If it does affect styling, the attribute gives you a narrower recalculation. Either way, the attribute is never worse than a class, and it is substantially better.
FAQ: Four Common Questions About classList and State
Does classList.add() accept a string with spaces, like "foo bar"?
No. It treats the entire string as a single token, which is invalid because it contains whitespace, and it silently fails. Use separate arguments: classList.add('foo', 'bar'), or spread an array: classList.add(...['foo', 'bar']).
What does classList.toggle() return, and when should I use the force parameter?
The method returns a boolean: true if the class is now present, false if removed. The force parameter overrides: toggle(token, true) always adds, toggle(token, false) always removes. Use force when you want to set a class to match a known condition without branching.
Is className faster than classList for performance-critical code?
Measured differences are negligible in modern engines. The real cost is style recalculation, which is identical for both. Use classList for new code; use className only as a fallback for pre-2015 browsers that lack classList.
Can I use a data-* attribute in CSS with the same specificity as a class?
Yes. [data-state="value"] has specificity (0,1,0), exactly like a class selector. This lets you style based on state without fighting the cascade, and it keeps the state readable by JavaScript via the dataset property.
The Line You Must Not Cross
The rule that makes this guide useful is also the rule that will keep you out of trouble: never use a class to store state that any code needs to read back. A class is a styling hook; a data-* attribute is a state container; a CSS custom property is a computed value. They overlap, but the overlap is the exception, not the rule. When you catch yourself writing classList.contains('is-open') to decide whether to show a panel, stop and move the state to a data attribute. The refactor is small, and it pays off the first time you need to change the styling without touching the logic.
There is an honest caveat at the end: this pattern is not a silver bullet. Some state is visual by nature. A hover effect is a class, not a data attribute. Some values belong in custom properties because they are used in calculations. The distinction is pragmatic, not moral. If a class reads naturally as a visual variant and nothing reads it back, use it. The moment it becomes a semantic flag, move it to data-state. Your future self, and the developer who inherits your code, will thank you for not making them parse the class attribute to understand the program.