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.

The panel's four statesToggling during a transitional state reverses it instead of stacking classes.The panel's four statesclosedopeningopenclosingtoggleanimationendtoggle / Escanimationend
Toggling during a transitional state reverses it instead of stacking classes.

Step-by-step resolution

Refactoring classes into a state machineWrite the table first; the code follows from it.Refactoring classes into a state machine1List the settled and transitional states the component can be in.closed, opening, open, closing2Write a transition table: state plus event gives next state.Every event order has a defined result3Replace all state classes with one data-state attribute.One source of truth4Move each animation to an attribute selector.CSS reacts to state, not to events5Advance transitional states on animationend and animationcancel.No stuck states6Derive aria-expanded, inert and focus from the same state.
Write the table first; the code follows from it.

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.

Transition table for the panelBlank cells are ignored events; nothing can put the panel in two states.Transition table for the paneltoggleescapedoneclosedopening(ignored)(ignored)openingclosing(ignored)openopenclosingclosing(ignored)closingopening(ignored)closed
Blank cells are ignored events; nothing can put the panel in two states.

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 animationend from 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 done event — for example when display: none cancels the animation — must be covered by animationcancel.

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.