# Disney Principles Are For Character Motion, Not For Chrome

**SHOULD** · **ID:** `animations-lottie-disney-scope` · **Category:** animations
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-disney-scope

> SHOULD: Ask "character or control" before reaching for Disney motion. Anticipation, squash-and-stretch, follow-through and overshoot belong to CHARACTERS — a mascot, an illustration, an empty-state figure, a confetti burst, a once-per-session celebration — where implied mass and personality are the point. They do NOT belong to CHROME: a dropdown, toast, menu or button is a control the user operates, and overshoot there reads as dated (that is `animations-impeccable-no-bounce-easing`, and it is not in conflict — it describes a different object). LottieFiles scopes it the same way: skip anticipation for micro-feedback (<150ms), skip squash-and-stretch for premium/luxury, and its exaggeration budget is 15-25% Playful but 0-5% Corporate and 0% Premium. Controls: ease-out, no overshoot.

Anticipation, squash-and-stretch and overshoot belong to a mascot, an illustration or a celebration — not to a dropdown, a toast or a menu

This is a REAL and deliberately preserved tension in the corpus, and the resolution is scope, not a winner. animations-impeccable-no-bounce-easing bans overshoot outright — any cubic-bezier whose control points leave the [-0.1, 1.1] band — and it is right about the surface it is talking about: a dropdown that wobbles, a toast that springs past its mark, a menu that squashes on open reads as dated on first sight and as broken by the fiftieth, because those elements are chrome the user operates, not characters the user watches. LottieFiles is not contradicting that; it is talking about a different object. Disney's vocabulary was built for CHARACTERS — things with implied mass, personality and intent — and it survives on the web wherever that is still true: a mascot, an illustration, an empty-state figure, a confetti burst, a success celebration, an onboarding moment seen once. And LottieFiles scopes it itself, which is the tell: squash-and-stretch says "Skip for premium/luxury brands", anticipation says "Skip for micro-feedback (<150ms)" — which is nearly every button and hover in a product — and its own exaggeration budget drops to 0-5% for Corporate and 0% for Premium, i.e. to exactly the flat, no-overshoot motion the impeccable rule demands. So the operative question is never "is bounce allowed" but "is this thing a character or a control". Controls: ease-out, no overshoot, done. Characters, once per session: anticipate, overshoot, follow through — that is where the charm lives, and refusing it there buys you nothing. animations-emil-subtle-bounce marks the one genuine middle ground (bounce 0.1–0.3, reserved for drag-to-dismiss, where the user's own thrown gesture has already established momentum, so the mass is real rather than asserted).

## Bad — do not do this

`animations-lottie-disney-scope-bad`

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

/**
 * Bad: the full Disney recipe — anticipation, then overshoot, then a squash settle —
 * applied to a DROPDOWN. This is chrome the user operates dozens of times a day,
 * not a character they watch once. The control points of this curve leave the
 * [0, 1] band (y2 = 1.56), which is exactly what overshoot IS, and what
 * `animations-impeccable-no-bounce-easing` bans on this kind of surface.
 */
const BACK_EASE = 'cubic-bezier(0.34, 1.56, 0.64, 1)';
const ANTICIPATION_MS = 140;
const OPEN_MS = 420;

const ITEMS = ['Rename', 'Duplicate', 'Delete'];

export function LottieDisneyScopeBad() {
  const [run, setRun] = useState(0);
  // 'closed' -> 'anticipating' (wind-up: menu shrinks away first) -> 'open' (overshoots back)
  const [phase, setPhase] = useState<'closed' | 'anticipating' | 'open'>('closed');
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    if (run === 0) return;
    setPhase('closed');
    const t1 = setTimeout(() => setPhase('anticipating'), 20);
    const t2 = setTimeout(() => setPhase('open'), 20 + ANTICIPATION_MS);
    return () => {
      clearTimeout(t1);
      clearTimeout(t2);
    };
  }, [run]);

  const menuStyle = reduced
    ? {
        opacity: phase === 'open' ? 1 : 0,
        transform: 'none',
        transition: 'opacity 150ms linear',
      }
    : {
        opacity: phase === 'closed' ? 0 : 1,
        // Anticipation: the menu pulls AWAY (scale 0.9, up 4px) before it opens.
        // Then it overshoots its final size on a back-ease and wobbles into place.
        transform:
          phase === 'closed'
            ? 'scale(0.92) translateY(-4px)'
            : phase === 'anticipating'
              ? 'scale(0.9) translateY(-6px)'
              : 'scale(1) translateY(0)',
        transformOrigin: 'top center',
        transition:
          phase === 'anticipating'
            ? `transform ${ANTICIPATION_MS}ms ease-in, opacity ${ANTICIPATION_MS}ms ease-in`
            : `transform ${OPEN_MS}ms ${BACK_EASE}, opacity 160ms ease-out`,
      };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setRun((r) => r + 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"
        >
          Replay menu open
        </button>
        <ReducedMotionSwitch />
      </div>

      <div className="relative h-52 rounded-lg border border-border bg-muted/40 p-6">
        <div className="inline-block rounded-md border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground">
          Actions ▾
        </div>

        <ul
          className="mt-2 w-44 space-y-1 rounded-lg border border-border bg-card p-2 shadow-lg"
          style={menuStyle}
          aria-hidden={phase !== 'open'}
        >
          {ITEMS.map((item) => (
            <li key={item} className="rounded-md px-2 py-1.5 text-xs text-card-foreground">
              {item}
            </li>
          ))}
        </ul>
      </div>

      <p className="text-xs text-destructive">
        The menu winds up backwards before it opens, then sails past its final size and springs back
        (cubic-bezier(0.34, 1.56, 0.64, 1) — y2 = 1.56 is the overshoot). Charming once; on the fiftieth open today it
        reads as dated, and as a control that is not quite sure where it belongs.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-lottie-disney-scope-good`

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

/**
 * Good: the SAME two Disney techniques, sorted by what the object is.
 *
 *  - The dropdown is CHROME the user operates dozens of times a day. Corporate
 *    budget: 0% overshoot, plain ease-out, 150ms. No anticipation (LottieFiles
 *    itself says "Skip for micro-feedback (<150ms)").
 *  - The success character is a CHARACTER the user watches, once. It gets the full
 *    recipe — anticipation, overshoot, follow-through — and that is where the
 *    charm actually lands.
 *
 * Same page, same product, two different motion budgets, on purpose.
 */
const UI_EASE = 'cubic-bezier(0.23, 1, 0.32, 1)'; // strong ease-out, stays inside [0,1]
const BACK_EASE = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; // overshoot — reserved for the character
const MENU_MS = 150;
const ANTICIPATION_MS = 160;
const POP_MS = 420;

const ITEMS = ['Rename', 'Duplicate', 'Delete'];

export function LottieDisneyScopeGood() {
  const [run, setRun] = useState(0);
  const [menuOpen, setMenuOpen] = useState(false);
  const [charPhase, setCharPhase] = useState<'hidden' | 'anticipating' | 'popped'>('hidden');
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    if (run === 0) return;
    setMenuOpen(false);
    setCharPhase('hidden');

    // Chrome: opens immediately, no wind-up.
    const t1 = setTimeout(() => setMenuOpen(true), 20);
    // Character: gathers, then bursts. It is allowed to take its time.
    const t2 = setTimeout(() => setCharPhase('anticipating'), 400);
    const t3 = setTimeout(() => setCharPhase('popped'), 400 + ANTICIPATION_MS);
    return () => {
      clearTimeout(t1);
      clearTimeout(t2);
      clearTimeout(t3);
    };
  }, [run]);

  // ── Chrome: flat ease-out, no overshoot, no anticipation. ──
  const menuStyle = reduced
    ? { opacity: menuOpen ? 1 : 0, transform: 'none', transition: 'opacity 150ms linear' }
    : {
        opacity: menuOpen ? 1 : 0,
        transform: menuOpen ? 'scale(1) translateY(0)' : 'scale(0.98) translateY(-4px)',
        transformOrigin: 'top center',
        transition: `transform ${MENU_MS}ms ${UI_EASE}, opacity ${MENU_MS}ms ${UI_EASE}`,
      };

  // ── Character: anticipation (compress) → overshoot pop → settle. ──
  const characterStyle = reduced
    ? {
        opacity: charPhase === 'popped' ? 1 : 0,
        transform: 'none',
        transition: 'opacity 150ms linear',
      }
    : {
        opacity: charPhase === 'hidden' ? 0 : 1,
        transform:
          charPhase === 'hidden'
            ? 'scale(0.6)'
            : charPhase === 'anticipating'
              ? 'scale(0.82)' // wind-up: gathers before it bursts
              : 'scale(1)',
        transition:
          charPhase === 'anticipating'
            ? `transform ${ANTICIPATION_MS}ms ease-in, opacity ${ANTICIPATION_MS}ms ease-in`
            : `transform ${POP_MS}ms ${BACK_EASE}, opacity 160ms ease-out`,
      };

  // Follow-through: the ring trails the character out and fades.
  const ringStyle =
    reduced || charPhase !== 'popped'
      ? { opacity: 0, transform: 'scale(0.8)' }
      : {
          opacity: 0,
          transform: 'scale(1.7)',
          transition: `transform 520ms ${UI_EASE} 60ms, opacity 520ms ${UI_EASE} 60ms`,
        };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setRun((r) => r + 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"
        >
          Replay both
        </button>
        <ReducedMotionSwitch />
      </div>

      <div className="grid gap-4 sm:grid-cols-2">
        {/* CONTROL — no overshoot. */}
        <div className="rounded-lg border border-border bg-muted/40 p-4">
          <p className="mb-3 text-xs font-medium text-muted-foreground">Control — 0% overshoot</p>
          <div className="h-40">
            <div className="inline-block rounded-md border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground">
              Actions ▾
            </div>
            <ul
              className="mt-2 w-40 space-y-1 rounded-lg border border-border bg-card p-2 shadow-lg"
              style={menuStyle}
              aria-hidden={!menuOpen}
            >
              {ITEMS.map((item) => (
                <li key={item} className="rounded-md px-2 py-1.5 text-xs text-card-foreground">
                  {item}
                </li>
              ))}
            </ul>
          </div>
        </div>

        {/* CHARACTER — the full recipe, once. */}
        <div className="rounded-lg border border-border bg-muted/40 p-4">
          <p className="mb-3 text-xs font-medium text-muted-foreground">Character — anticipate, overshoot, follow through</p>
          <div className="flex h-40 items-center justify-center">
            <div className="relative">
              <div
                aria-hidden="true"
                className="absolute inset-0 rounded-full border-2 border-success"
                style={ringStyle}
              />
              <div
                className="relative flex size-16 items-center justify-center rounded-full bg-success text-2xl text-success-foreground"
                style={characterStyle}
                role="img"
                aria-label="Upload complete"
              >
                ✓
              </div>
            </div>
          </div>
        </div>
      </div>

      <p className="text-xs text-success">
        Same product, two budgets. The menu the user opens fifty times a day gets a flat 150ms ease-out that stays out of
        the way. The celebration they see once gets the wind-up, the overshoot and the trailing ring — because it is a
        character, not a control. Reduced motion keeps both end states and drops the travel.
      </p>
    </div>
  );
}
```

## References

- [LottieFiles — Disney principles, UI adapted](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/director/disney-principles.md)
- [LottieFiles — motion personality archetypes](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/director/motion-personality.md)
- [MDN — easing-function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function)
