← Notes
21 Mar 2024 CSS

Abusing @keyframes as a state machine (it works)

animation-play-state + animation-delay with a negative value lets you scrub to any frame of an animation. Which means you can use CSS animations as state machines.

  • #css
  • #animation
  • #patterns
  • #weird

Something technically inadvisable that works perfectly in production.

CSS animation-play-state: paused + a negative animation-delay lets you scrub to any keyframe of an animation. The browser renders the frame at that offset and doesn’t advance. Move between states by updating the delay value — via a custom property, a checkbox, or a data- attribute.

This is a state machine. With CSS.

@keyframes toggle-switch {
  0%   { transform: translateX(0);    background: #999; }
  100% { transform: translateX(24px); background: #22c55e; }
}

.toggle-thumb {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  animation: toggle-switch 200ms ease both;
  animation-play-state: paused;
  animation-delay: calc(var(--state, 0) * -200ms);
}

/* When the hidden checkbox is checked, jump to the end frame */
input:checked ~ .toggle .toggle-thumb {
  --state: 1;
}

When --state is 0, delay is 0ms — the animation is at its start frame. When --state is 1, delay is -200ms — the animation is “200ms into” a 200ms animation, which is the end frame. Nothing plays. You’re statically positioned at a keyframe.

Change --state to 0.5 and you’re at the exact midpoint. The browser interpolates.

Browser compatibility: This technique uses only longstanding CSS features — no new APIs. Tested in Chrome 100+, Firefox 100+, Safari 15.4+. The transition: animation-delay approach (see below) works in Chrome and Firefox; Safari may not transition animation-delay as a standalone property. The custom property approach with @property registration is more reliable cross-browser.

A working toggle with transition between states

The state change can still be animated — not by playing the animation forward, but by animating the delay value:

.toggle-thumb {
  animation: toggle-switch 200ms ease both;
  animation-play-state: paused;
  animation-delay: calc(var(--state, 0) * -200ms);
  transition: animation-delay 200ms ease;
}

When --state switches from 0 to 1, the delay transitions from 0 to -200ms. Because the delay change is animated, the browser scrubs through the keyframes on the way. The toggle slides.

For more reliable cross-browser behavior, register the custom property and transition it directly:

@property --state {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

.toggle-thumb {
  animation: toggle-switch 200ms ease both;
  animation-play-state: paused;
  animation-delay: calc(var(--state) * -200ms);
  transition: --state 200ms ease;
}

This is the kind of thing that makes you suspicious the CSS spec authors knew exactly what they were doing.

↓ Click the toggle — pure CSS state machine

A stepper component (three states)

@keyframes step-progress {
  0%   { width: 0%; }
  33%  { width: 33.3%; }
  66%  { width: 66.6%; }
  100% { width: 100%; }
}

.step-bar {
  animation: step-progress 300ms ease both;
  animation-play-state: paused;
  animation-delay: calc(var(--step, 0) * -100ms);
  transition: animation-delay 300ms ease;
}
<div class="stepper" style="--step: 0">
  <div class="step-bar"></div>
</div>

Increment --step with a single style.setProperty call and the progress bar smoothly advances to the corresponding keyframe. Three states. One animation. No per-state class switching.

When to actually use this

This technique shines for:

  • Binary toggles that need smooth transitions (switches, expand/collapse)
  • Progress indicators with fixed step counts
  • Icon morphs where start and end states are defined but intermediate is decorative

It’s a bad fit for:

  • Many states — more than ~5 keyframe jumps gets hard to reason about
  • Conditional transitions — different animation between state A→B vs B→A requires two animation definitions
  • Anything that needs accurate progress reporting — you’re scrubbing CSS, not reading a clock

The real reason to know this technique isn’t to use it everywhere. It’s to understand that the animation system you think of as “play something over time” is actually a “map time to state” system — and time is just one of several possible inputs.

Negative delays are a scrubber. That’s always been true. We just rarely needed to exploit it.