# Transitions Over Keyframes for Retriggered Motion

**SHOULD** · **ID:** `animations-emil-transitions-over-keyframes` · **Category:** animations
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-emil-transitions-over-keyframes

> SHOULD: Drive rapidly-retriggered motion (toasts, toggles) with CSS `transition` or a spring, not `@keyframes` — a transition retargets from the element's current value, while a keyframe animation restarts from its `from` and visibly teleports.

Drive rapidly-retriggered motion with CSS transitions, not keyframes, so it retargets instead of restarting

A transition interpolates from the element's current computed value, so interrupting it mid-flight simply re-aims at the new target from wherever the element is. A keyframe animation is absolute: it always plays its declared `from` → `to`, so retriggering it teleports the element back to the start before it moves again. Spam-click a toggle and the difference is unmistakable. Springs share the transition property here — they carry velocity through an interruption — which is why they suit reversible gestures.

## Bad — do not do this

`animations-emil-transitions-over-keyframes-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const TRAVEL = 200;

/**
 * Bad: the knob is driven by keyframes. Every retrigger restarts the animation
 * from its declared `from` value, so an interrupted toggle snaps back to the
 * far end before it starts moving again.
 */
export function EmilTransitionsOverKeyframesBad() {
  const knobRef = useRef<HTMLDivElement>(null);
  const runningRef = useRef<Animation | null>(null);
  const firstRender = useRef(true);
  const [on, setOn] = useState(false);

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }
    const el = knobRef.current;
    if (!el) return;

    // Keyframes are absolute: they always play from `from` to `to`,
    // regardless of where the element currently is.
    runningRef.current?.cancel();
    runningRef.current = el.animate(
      [
        { transform: `translateX(${on ? 0 : TRAVEL}px)` },
        { transform: `translateX(${on ? TRAVEL : 0}px)` },
      ],
      { duration: 600, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
    );
  }, [on]);

  return (
    <div className="w-full max-w-sm space-y-4">
      <button
        onClick={() => setOn((v) => !v)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Toggle — now spam-click it
      </button>

      <div className="relative h-16 rounded-full bg-muted p-2 overflow-hidden">
        <div ref={knobRef} className="h-12 w-12 rounded-full bg-primary" />
      </div>

      <p className="text-xs text-error">
        Spam-click mid-motion: the knob teleports back to the start of the keyframe before moving
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-transitions-over-keyframes-good`

```tsx
import { useState } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';

const TRAVEL = 200;

/**
 * Good: the knob is driven by a CSS transition. A transition interpolates from
 * the *current computed value*, so interrupting it retargets smoothly from
 * wherever the knob happens to be — no restart, no snap.
 */
export function EmilTransitionsOverKeyframesGood() {
  const [on, setOn] = useState(false);
  const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <div className="w-full max-w-sm space-y-4">
      <button
        onClick={() => setOn((v) => !v)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Toggle — now spam-click it
      </button>

      <div className="relative h-16 rounded-full bg-muted p-2 overflow-hidden">
        <div
          className="h-12 w-12 rounded-full bg-primary"
          style={{
            transition: reduced
              ? 'none'
              : 'transform 600ms cubic-bezier(0.23, 1, 0.32, 1)',
            transform: `translateX(${on ? TRAVEL : 0}px)`,
          }}
        />
      </div>

      <p className="text-xs text-success">
        Spam-click mid-motion: the transition retargets from the knob's current position. Reduced motion jumps instantly.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — Using CSS transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Transitions/Using)
- [MDN — Using CSS animations](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Animations/Using)
