Animation State Machines with Data Attributes
Part of Animation State Management in Core CSS Animation Fundamentals.
The problem
A slide-out panel has grown classes over time: is-open, is-closing, is-animating, was-opened, no-transition. Opening it quickly after closing leaves it with both is-open and is-closing. Pressing Escape during the opening animation leaves is-animating stuck, so the next open has no motion. A bug fix for one sequence breaks another, because the valid combinations of five booleans were never written down.
Animated components have more states than static ones — every “open” needs an “opening” — and independent boolean classes let the component be in states that make no sense.
Root cause analysis: booleans allow impossible combinations
Five independent classes describe thirty-two combinations. The panel really has four meaningful states: closed, opening, open and closing. Every other combination is a bug waiting for a particular event order.
A finite state machine makes the valid states explicit and the transitions between them deliberate. The component is in exactly one state at a time. An event — a click, Escape, the end of an animation — is either valid in the current state, in which case it moves to a defined next state, or it is ignored or redirected.
A single attribute holds the state. data-state="opening" on the root is visible in DevTools, trivially selectable in CSS, and cannot be in two values at once. CSS attaches motion to states: the opening animation to [data-state="opening"], the resting styles to [data-state="open"]. Script owns only the transitions.
Animation events advance transitional states. “Opening” ends when the opening animation ends. That makes animationend part of the state machine — along with animationcancel, because an interrupted animation still needs to land the component somewhere valid, the reliability problem covered in handling animationend and transitionend reliably.
Step-by-step resolution
Production code pattern
const TRANSITIONS = {
closed: { toggle: 'opening' },
opening: { toggle: 'closing', done: 'open' },
open: { toggle: 'closing', escape: 'closing' },
closing: { toggle: 'opening', done: 'closed' },
};
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
function send(panel, event) {
const current = panel.dataset.state;
let next = TRANSITIONS[current]?.[event];
if (!next) return; // event not valid in this state: ignore
if (reduce.matches) { // no transitional states without motion
if (next === 'opening') next = 'open';
if (next === 'closing') next = 'closed';
}
panel.dataset.state = next;
const expanded = next === 'opening' || next === 'open';
panel.inert = !expanded;
trigger.setAttribute('aria-expanded', String(expanded));
}
panel.addEventListener('animationend', (e) => {
if (e.target === panel) send(panel, 'done');
});
panel.addEventListener('animationcancel', (e) => {
if (e.target === panel) send(panel, 'done'); // an interrupted animation still settles
});
trigger.addEventListener('click', () => send(panel, 'toggle'));
panel.addEventListener('keydown', (e) => { if (e.key === 'Escape') send(panel, 'escape'); });
.panel[data-state="closed"] { translate: 100% 0; visibility: hidden; }
.panel[data-state="open"] { translate: 0 0; }
.panel[data-state="opening"] { animation: panel-in 280ms cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.panel[data-state="closing"] { animation: panel-out 220ms ease-in both; }
@keyframes panel-in { from { translate: 100% 0; } to { translate: 0 0; } }
@keyframes panel-out { from { translate: 0 0; } to { translate: 100% 0; } }
@media (prefers-reduced-motion: reduce) {
.panel[data-state="opening"], .panel[data-state="closing"] { animation: none; }
}
Rendering Impact: composite. Each state change swaps which animation applies; the animations themselves only touch
translate. Attribute changes cost one style recalculation per event, not per frame.
The reversal cases are where the machine earns its keep. Toggling during opening moves to closing, which starts panel-out from its first keyframe — a slight jump if the panel was half open. When that matters, replace the keyframes with a transition on translate between the settled states, which reverses from its current value, and keep the transitional states purely for timing and ARIA.
Debugging with a visible state
A single attribute makes the component’s state observable. In the Elements panel, data-state updates live as you interact; a DOM breakpoint on attribute modifications pauses exactly when the state changes, showing the call stack that caused it. In tests, asserting data-state is more robust than asserting classes, and it aligns with the motion-state assertions in writing Playwright tests for motion states.
Verification checklist
Constraints and trade-offs
- Keyframe animations restart on reversal; use transitions where mid-flight reversal must be seamless.
- Bubbling
animationendfrom child animations must be filtered by target. - More states make the table larger but not the bugs more numerous; resist merging states to save lines.
- Framework state and DOM attributes can diverge if both are written; derive one from the other.
- A missing
doneevent — for example whendisplay: nonecancels the animation — must be covered byanimationcancel.
Frequently asked questions
Why use a data attribute instead of classes?
An attribute holds one value, so the component cannot be in two states at once. Classes are independent booleans and allow impossible combinations.
What happens if the user clicks during an animation?
The transition table decides. For a panel, toggling during opening moves to closing, so the panel reverses instead of accumulating classes.
Do I need a state machine library?
Not for a single component. A plain object mapping states and events to next states is enough. Libraries help when states are nested or shared across components.
How does reduced motion fit into the machine?
Skip transitional states: opening goes straight to open and closing straight to closed, so no animation events are needed.
Related
- Animation State Management — the parent topic
- Mapping UI States to CSS Custom Properties — state-driven values
- Cancelling and Restarting Without Flicker — restarts inside a state machine