This article covers how to detect when an element’s class changes and run code in response.
The short answer: use MutationObserver. The other approaches exist, but none of them has a reason to win in production code.
This article originally compared several techniques. One of them, DOMAttrModified, has since been removed from browsers and no longer works — Chrome dropped it in version 127, released July 2024. Each method is covered below along with its current status.
▼Status of each approach
| Method | Status |
|---|---|
MutationObserver |
Use this |
setInterval polling |
Works, but wasteful |
Proxy |
Misses direct changes |
Object.defineProperty |
Risks breaking standard behaviour |
DOMAttrModified |
Removed. Does not fire |
animationend |
Different purpose |
Sponsored
Watching for class changes with MutationObserver
MutationObserver is the standard way to watch for DOM changes. For class attributes, treat it as the only option worth considering.
It batches changes and delivers them together, so a single operation that rewrites an attribute several times results in one callback. It also does not block the browser’s own optimisations, which is where the older mutation events fell down.
const targetElement = document.querySelector('.target-element');
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
// handle the class change here
}
});
});
// watch the class attribute only
const config = {
attributes: true,
attributeFilter: ['class'], // narrow it down
attributeOldValue: true // receive the previous value
};
observer.observe(targetElement, config);
Use attributeFilter to narrow the scope. With just { attributes: true }, the callback fires for every attribute including style and any data-*. If you only care about the class, say so.
Setting attributeOldValue: true gives you mutation.oldValue in the callback, which is what you need to detect the moment a particular class is added.
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const before = mutation.oldValue || '';
const after = mutation.target.className;
if (!before.includes('is-active') && after.includes('is-active')) {
// the moment is-active was added
}
}
});
Call disconnect() when you are done. Forgetting this leaves the observer running after the element has been removed.
observer.disconnect();
Polling with setInterval
Checking the class at a fixed interval is simple to write, but the work happens whether or not anything changed. At 100 ms that is 600 checks a minute, and detection lags by up to 100 ms.
Unless something prevents you from using MutationObserver, there is no reason to choose this.
const targetElement = document.querySelector('.target-element');
let previousClass = targetElement.className;
setInterval(() => {
if (targetElement.className !== previousClass) {
previousClass = targetElement.className;
// handle the class change here
}
}, 100);
Sponsored
Wrapping the element in a Proxy
A Proxy can intercept property assignment, but the constraint is severe: it only sees changes made through the proxy object. Anything that touches the original element directly — including other libraries — goes undetected.
There is rarely a real use case for this when watching classes.
const targetElement = document.querySelector('.target-element');
const handler = {
set(target, property, value) {
if (property === 'className') {
// handle the class change here
}
target[property] = value;
return true;
}
};
const proxyElement = new Proxy(targetElement, handler);
proxyElement.className = 'new-class'; // only this is detected
Overriding className with Object.defineProperty
This one is best avoided because it breaks standard behaviour. Replacing className with your own getter and setter can stop changes made through classList.add() from being reflected. It may appear to work and then produce a bug that is very hard to trace.
const targetElement = document.querySelector('.target-element');
let classNameValue = targetElement.className;
Object.defineProperty(targetElement, 'className', {
get() {
return classNameValue;
},
set(newValue) {
classNameValue = newValue;
// handle the class change here
}
});
Sponsored
DOMAttrModified no longer works
This approach is dead. Mutation events including DOMAttrModified were deprecated in 2011 and removed from Chrome in version 127, July 2024. Firefox logs a deprecation warning.
The code below is kept for reference only. Written today, the event never fires. Replace it with the MutationObserver approach above.
const targetElement = document.querySelector('.target-element');
targetElement.addEventListener('DOMAttrModified', (event) => {
if (event.attrName === 'class') {
// this callback will not run in current browsers
}
});
Using the CSS animation end event
This does not detect a class change. It catches the end of a CSS animation that a class change happened to start.
Use it for things like “add a class, animate, then remove the element once the animation finishes”. For transitions, listen for transitionend instead.
const targetElement = document.querySelector('.target-element');
targetElement.addEventListener('animationend', () => {
// runs when the animation finishes
});
Summary
To detect class changes, use MutationObserver. The alternatives are either dead (DOMAttrModified), wasteful (setInterval), incomplete (Proxy), or destructive (Object.defineProperty).
Three things to remember when using it:
- Narrow the scope with
attributeFilter: ['class'] - Add
attributeOldValue: trueif you need the previous value - Call
disconnect()when you no longer need it
Better still, avoid needing to watch at all. If your own code is the thing adding and removing the class, call the handler directly from there. Observation is only necessary when something outside your control — a third-party library, say — rewrites the class.
For traversing from one element to another, getting parent elements in jQuery also covers the plain JavaScript equivalents. For a worked example of toggling classes to change appearance, see building an accordion menu.