# Input-driven

**NEVER** · **ID:** `animations-input-driven` · **Category:** animations
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-input-driven

> NEVER: Autoplay motion just because a component mounted — the only thing that happened is that the user arrived. Animate in response to an input: hover, press, drag, scroll-into-view, or a request completing. Autoplay loops also keep the compositor awake and become a WCAG 2.2.2 problem past 5s.

Avoid autoplay — animate in response to a user action, not on arrival

An animation that plays because the component mounted is asserting that something happened, when the only thing that happened is that the user arrived. Motion should be the answer to an input — a hover, a press, a drag, a scroll bringing the element into view, a request completing. Autoplay loops are the worst offender: they never stop competing for attention, they keep the compositor awake and drain battery, and past 5 seconds they also become a WCAG 2.2.2 problem. This is not the same rule as animations-interruptible (which is about a running animation yielding to input) or animations-pause-stop-hide (which accepts autoplay and demands controls for it). This one asks the prior question: should it have started by itself at all?

## Bad — do not do this

`animations-input-driven-bad`

```tsx
import { useState } from 'react';

/**
 * Bad: the hero animates *at* you. Nothing asked for it — the badge floats and
 * the arrow bobs on an infinite loop from the moment the section mounts, and
 * they will still be doing it in five minutes.
 */
export function InputDrivenBad() {
  const [mountKey, setMountKey] = useState(0);

  return (
    <div className="w-full space-y-4">
      <button
        onClick={() => setMountKey((k) => k + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Remount hero
      </button>

      <div key={mountKey} className="rounded-lg border border-border bg-card p-5 text-center">
        <span
          className="inline-block rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
          style={{ animation: 'idFloat 1.6s ease-in-out infinite alternate' }}
        >
          New
        </span>
        <p className="mt-3 text-sm font-medium text-card-foreground">Ship in one command</p>
        <span className="mt-3 inline-flex items-center gap-1 text-sm text-muted-foreground">
          Get started
          <span
            aria-hidden="true"
            className="inline-block"
            style={{ animation: 'idBob 1.1s ease-in-out infinite alternate' }}
          >
            →
          </span>
        </span>
      </div>

      <style>{`
        @keyframes idFloat { from { transform: translateY(0); } to { transform: translateY(-5px); } }
        @keyframes idBob { from { transform: translateX(0); } to { transform: translateX(6px); } }
      `}</style>

      <p className="text-xs text-destructive">
        Autoplay, forever, on mount. It burns attention (and battery) whether or not the user is even looking at it.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-input-driven-good`

```tsx
import { useEffect, useRef, useState } from 'react';
import { ReducedMotionSwitch } from '@/components/demo-kit/ReducedMotionSwitch';
import { useSimulatedReducedMotion } from '@/hooks/useSimulatedReducedMotion';

/**
 * Good: exactly the same two movements — the badge lifts, the arrow slides —
 * but each one now answers an input. The badge reacts to the section scrolling
 * into view (once), the arrow reacts to hover/focus on the link. Nothing loops,
 * nothing plays unprompted.
 */
export function InputDrivenGood() {
  const [inView, setInView] = useState(false);
  const [hovered, setHovered] = useState(false);
  const sectionRef = useRef<HTMLDivElement | null>(null);
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    const node = sectionRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) setInView(true);
      },
      { threshold: 0.5 }
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  const badgeMotion = reduced
    ? { transition: 'opacity 150ms linear', transform: 'none' }
    : {
        transition: 'transform 260ms cubic-bezier(0.23, 1, 0.32, 1), opacity 260ms cubic-bezier(0.23, 1, 0.32, 1)',
        transform: inView ? 'translateY(0)' : 'translateY(-5px)',
      };

  const arrowMotion = reduced
    ? { transform: 'none' }
    : {
        transition: 'transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',
        transform: hovered ? 'translateX(6px)' : 'translateX(0)',
      };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setInView(false)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Reset scroll reveal
        </button>
        <ReducedMotionSwitch />
      </div>

      <div ref={sectionRef} className="rounded-lg border border-border bg-card p-5 text-center">
        <span
          className="inline-block rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
          style={{ ...badgeMotion, opacity: inView ? 1 : 0 }}
        >
          New
        </span>
        <p className="mt-3 text-sm font-medium text-card-foreground">Ship in one command</p>
        <button
          onMouseEnter={() => setHovered(true)}
          onMouseLeave={() => setHovered(false)}
          onFocus={() => setHovered(true)}
          onBlur={() => setHovered(false)}
          className="mt-3 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Get started
          <span aria-hidden="true" className="inline-block" style={arrowMotion}>
            →
          </span>
        </button>
      </div>

      <p className="text-xs text-success">
        Same motion, now earned: the badge plays once when the section is scrolled into view, the arrow answers
        hover/focus. Idle means idle. Reduced motion keeps the states and drops the movement.
      </p>
    </div>
  );
}
```

## References

- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [NN/g — Animation for Attention and Comprehension](https://www.nngroup.com/articles/animation-usability/)
- [MDN — Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
