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.
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-delayapproach (see below) works in Chrome and Firefox; Safari may not transitionanimation-delayas a standalone property. The custom property approach with@propertyregistration 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.
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.