# Extra Animation Is an AI Tell

**SHOULD** · **ID:** `aesthetics-scroll-interactions` · **Category:** aesthetics
**Source:** [Skills](https://skills.sh/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/aesthetics-scroll-interactions

> SHOULD: Use IntersectionObserver for scroll-triggered reveals. Stagger animations with animation-delay (100-150ms intervals). Keep animations subtle (translate 10-20px, fade 0 to 1). Use motion-safe: to respect reduced motion preferences. Transform passive browsing into active discovery.

Reveal-on-scroll everything and the page reads as generated — animate only where motion carries meaning

This principle used to say the opposite — that scroll-triggering should "surprise and delight." Upstream now names delight-maximalism as the tell: when every section fades up, every card lifts on hover, every number counts itself in, the motion stops meaning anything and starts reading as a generator's default. Reserve the reveal for content that genuinely benefits from being staged, and let the rest of the page simply be there when the reader arrives. A page that animates one thing is read as confident; a page that animates everything is read as machine-made.

## Bad — do not do this

`aesthetics-scroll-interactions-bad`

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

const features = [
  { title: 'Lightning Fast', description: 'Built for speed with optimized performance' },
  { title: 'Secure by Default', description: 'Enterprise-grade security out of the box' },
  { title: 'Scale Infinitely', description: 'Grow without limits or constraints' },
  { title: 'Always Available', description: 'Uptime you can plan a business around' },
];

export function ScrollInteractionsBad() {
  const [visible, setVisible] = useState<Set<number>>(new Set());
  const [resetKey, setResetKey] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);

  useEffect(() => {
    setVisible(new Set());

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const index = Number(entry.target.getAttribute('data-index'));
            setVisible((prev) => new Set([...prev, index]));
          }
        });
      },
      { root: containerRef.current, threshold: 0.3 }
    );

    itemRefs.current.forEach((ref) => {
      if (ref) observer.observe(ref);
    });

    return () => observer.disconnect();
  }, [resetKey]);

  const handleReset = () => {
    setVisible(new Set());
    if (containerRef.current) containerRef.current.scrollTop = 0;
    setResetKey((key) => key + 1);
  };

  return (
    <div className="w-full max-w-md p-6 bg-card rounded-lg">
      <div className="flex items-center justify-between mb-4">
        {/* Even the heading refuses to just sit there */}
        <h3 className="text-lg font-semibold motion-safe:animate-pulse">Features</h3>
        <button
          onClick={handleReset}
          className="px-3 py-1.5 bg-primary text-primary-foreground rounded text-sm motion-safe:transition-transform motion-safe:hover:scale-110"
        >
          Reset &amp; scroll
        </button>
      </div>

      <div ref={containerRef} className="space-y-3 max-h-64 overflow-y-auto pr-2">
        {features.map((feature, index) => (
          <div
            key={`${feature.title}-${resetKey}`}
            ref={(el) => {
              itemRefs.current[index] = el;
            }}
            data-index={index}
            // Every card fades up, slides, scales, AND lifts on hover
            className="p-4 bg-muted rounded-lg border border-border motion-safe:transition-all motion-safe:duration-700 motion-safe:hover:-translate-y-1 motion-safe:hover:scale-[1.03] motion-safe:hover:shadow-lg"
            style={{
              opacity: visible.has(index) ? 1 : 0,
              transform: visible.has(index)
                ? 'translateY(0) scale(1)'
                : 'translateY(28px) scale(0.94)',
              transitionDelay: `${index * 180}ms`,
            }}
          >
            <h4 className="font-medium text-foreground motion-safe:transition-colors hover:text-primary">
              {feature.title}
            </h4>
            <p className="text-sm text-muted-foreground mt-1">{feature.description}</p>
            {/* A bar that animates itself in, meaning nothing */}
            <span
              className="mt-3 block h-1 rounded bg-primary motion-safe:transition-all motion-safe:duration-1000"
              style={{ width: visible.has(index) ? '100%' : '0%' }}
            />
          </div>
        ))}
      </div>

      <p className="text-xs text-error mt-4">
        Every section fades up, every card lifts, the heading pulses, the bars fill. Nothing here is
        a sequence, so none of the motion means anything &mdash; and the reader has to wait for a
        feature list to finish performing. Extra animation is the tell.
      </p>
    </div>
  );
}
```

## Good — do this

`aesthetics-scroll-interactions-good`

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

const features = [
  { title: 'Lightning Fast', description: 'Built for speed with optimized performance' },
  { title: 'Secure by Default', description: 'Enterprise-grade security out of the box' },
  { title: 'Scale Infinitely', description: 'Grow without limits or constraints' },
  { title: 'Always Available', description: 'Uptime you can plan a business around' },
];

export function ScrollInteractionsGood() {
  const [added, setAdded] = useState(false);
  const [nonce, setNonce] = useState(0); // remounts the row so the entrance replays each time
  const listRef = useRef<HTMLDivElement>(null);

  const ship = () => {
    if (added) {
      setAdded(false);
      return;
    }
    setAdded(true);
    setNonce((n) => n + 1);
    // Bring the new row into view so its one animation is actually seen.
    requestAnimationFrame(() => {
      if (listRef.current) listRef.current.scrollTop = 0;
    });
  };

  return (
    <div className="w-full max-w-md p-6 bg-card rounded-lg">
      <style>{`
        @keyframes scroll-good-in {
          from { opacity: 0; transform: translateY(18px) scale(0.95); }
          to   { opacity: 1; transform: translateY(0) scale(1); }
        }
        @media (prefers-reduced-motion: no-preference) {
          .scroll-good-row { animation: scroll-good-in 0.5s cubic-bezier(0.22, 1, 0.36, 1); }
        }
      `}</style>

      <div className="flex items-center justify-between mb-4">
        <h3 className="text-lg font-semibold">Features</h3>
        <button
          onClick={ship}
          className="px-3 py-1.5 bg-primary text-primary-foreground rounded text-sm motion-safe:transition-colors hover:bg-primary/90 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          {added ? 'Remove' : 'Ship a feature'}
        </button>
      </div>

      <div ref={listRef} className="space-y-3 max-h-64 overflow-y-auto pr-2">
        {/* The one animated element: it moves because something actually changed. */}
        {added && (
          <div
            key={nonce}
            className="scroll-good-row p-4 bg-muted rounded-lg border border-primary"
          >
            <h4 className="font-medium text-foreground">Audit Log</h4>
            <p className="text-sm text-muted-foreground mt-1">Just added to your plan</p>
          </div>
        )}

        {/* The list itself does not perform. It is simply there when you scroll to it. */}
        {features.map((feature) => (
          <div key={feature.title} className="p-4 bg-muted rounded-lg border border-border">
            <h4 className="font-medium text-foreground">{feature.title}</h4>
            <p className="text-sm text-muted-foreground mt-1">{feature.description}</p>
          </div>
        ))}
      </div>

      <p className="text-xs text-success mt-4">
        Press <span className="font-medium">Ship a feature</span>: motion is spent once, on the row that genuinely
        arrived &mdash; no reveal-on-scroll, no hover lift, no pulsing heading. When something moves here, it means
        something moved.
      </p>
    </div>
  );
}
```

## References

- [Anthropic frontend-design skill](https://github.com/anthropics/skills/blob/main/skills/frontend-design/SKILL.md)
- [MDN: prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
