# Three Motion Layers

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

> SHOULD: Build a hero moment in three layers — primary (the action the eye follows), secondary (shadow trailing ~50ms, contents arriving ~100ms after the card lands), ambient (background drift) — instead of pushing one flat sprite. SCOPE: three layers is three times the motion, so spend it only on rare first-run/hero surfaces (empty-state illustration, onboarding card, marketing hero). On anything repeated — a row rendered 400 times, a menu opened fifty times a day — `animations-necessity-check` and `animations-emil-frequency` win and the correct layer count is one, or zero.

Build a hero moment in three layers — primary, secondary, ambient — instead of moving one flat sprite

A card that only translates and fades is one layer: the shadow rides along at a fixed depth, the contents are fully legible before the card has landed, and nothing lives behind it, so the eye reads a single flat sprite being pushed into place. Adding the missing layers is cheap — the shadow becomes its own opacity-animated element that trails the card by ~50ms so it settles onto the surface, the row content arrives ~100ms after the card lands, and a few pixels of background drift keep the scene from being dead. HONEST TENSION: this rule pulls directly against animations-necessity-check and animations-emil-frequency. Three layers is three times the motion, and motion you have to justify. Spend it on the rare first-run or hero moment — an empty-state illustration, an onboarding card, a marketing surface — and never on a table row that renders 400 times or a menu opened fifty times a day, where the correct number of layers is one, or zero. This entry is kept alongside the necessity rule the same way animations-emil-no-ease-in is kept alongside animations-easing: the disagreement is the point, and you should read both before choosing.

## Bad — do not do this

`animations-lottie-motion-layers-bad`

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

const ROWS = ['Deploy · 2m ago', 'Build · 5m ago', 'Tests · 8m ago'];
const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';

/**
 * Bad: one motion layer. The card translates and fades — and that is the whole
 * animation. The shadow is baked into the card, so it slides along at a fixed
 * depth instead of settling. The rows are fully formed at frame 1 and ride up
 * with the card. Nothing lives behind it. The result reads as a single flat
 * sprite being pushed into place.
 */
export function LottieMotionLayersBad() {
  const [run, setRun] = useState(0);
  const [entered, setEntered] = useState(false);

  useEffect(() => {
    setEntered(false);
    let inner = 0;
    const outer = requestAnimationFrame(() => {
      inner = requestAnimationFrame(() => setEntered(true));
    });
    return () => {
      cancelAnimationFrame(outer);
      cancelAnimationFrame(inner);
    };
  }, [run]);

  return (
    <div className="w-full space-y-4">
      <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 entrance
      </button>

      <div className="relative h-52 overflow-hidden rounded-lg border border-border bg-muted/40 p-6">
        {/* No ambient layer: the background is dead. */}
        <div
          className="rounded-lg border border-border bg-card p-4 shadow-lg"
          style={{
            transition: `transform 320ms ${EASE}, opacity 320ms ${EASE}`,
            transform: entered ? 'translateY(0)' : 'translateY(20px)',
            opacity: entered ? 1 : 0,
          }}
        >
          <p className="text-sm font-medium text-card-foreground">Pipeline</p>
          {/* No secondary layer: the rows are already fully formed at frame 1. */}
          <ul className="mt-3 space-y-1.5">
            {ROWS.map((row) => (
              <li key={row} className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
                {row}
              </li>
            ))}
          </ul>
        </div>
      </div>

      <p className="text-xs text-destructive">
        Primary layer only. The shadow travels with the card at a fixed depth, the rows are already legible before
        the card has landed, and the background never moves — so the whole thing reads as one flat sprite sliding in.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-lottie-motion-layers-good`

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

const ROWS = ['Deploy · 2m ago', 'Build · 5m ago', 'Tests · 8m ago'];
const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';

/** Primary lands in 320ms; the shadow follows it in 50ms later; contents arrive 100ms after it lands. */
const PRIMARY_MS = 320;
const SHADOW_DELAY_MS = 50;
const CONTENT_DELAY_MS = PRIMARY_MS + 100;
const ROW_STAGGER_MS = 60;

/**
 * Good: the same card entrance, now built in three layers.
 *  - Primary   — the card rises and fades. This is the thing the eye follows.
 *  - Secondary — the shadow deepens 50ms behind the card (so it settles onto the
 *    surface instead of riding along), and the rows fade in 100ms after it lands.
 *  - Ambient   — a very low-amplitude background drift, so the scene is not dead.
 *
 * The shadow is a separate opacity-animated layer, not an animated box-shadow —
 * the depth cue is bought on the compositor, not in paint.
 */
export function LottieMotionLayersGood() {
  const [run, setRun] = useState(0);
  const [entered, setEntered] = useState(false);
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    setEntered(false);
    let inner = 0;
    const outer = requestAnimationFrame(() => {
      inner = requestAnimationFrame(() => setEntered(true));
    });
    return () => {
      cancelAnimationFrame(outer);
      cancelAnimationFrame(inner);
    };
  }, [run]);

  // Reduced motion keeps every layer's END state and drops the travel.
  const primary = reduced
    ? { transition: 'opacity 150ms linear', transform: 'none', opacity: entered ? 1 : 0 }
    : {
        transition: `transform ${PRIMARY_MS}ms ${EASE}, opacity ${PRIMARY_MS}ms ${EASE}`,
        transform: entered ? 'translateY(0)' : 'translateY(20px)',
        opacity: entered ? 1 : 0,
      };

  const shadow = reduced
    ? { opacity: entered ? 1 : 0, transition: 'opacity 150ms linear' }
    : {
        transition: `opacity ${PRIMARY_MS}ms ${EASE} ${SHADOW_DELAY_MS}ms`,
        opacity: entered ? 1 : 0,
      };

  const ambient = reduced
    ? { transform: 'none' }
    : {
        transition: 'transform 1200ms cubic-bezier(0.4, 0, 0.2, 1)',
        transform: entered ? 'translate(6px, -4px)' : 'translate(-6px, 4px)',
      };

  const rowStyle = (i: number) =>
    reduced
      ? { opacity: entered ? 1 : 0, transition: 'opacity 150ms linear' }
      : {
          transition: `transform 220ms ${EASE} ${CONTENT_DELAY_MS + i * ROW_STAGGER_MS}ms, opacity 220ms ${EASE} ${
            CONTENT_DELAY_MS + i * ROW_STAGGER_MS
          }ms`,
          transform: entered ? 'translateY(0)' : 'translateY(4px)',
          opacity: entered ? 1 : 0,
        };

  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 entrance
        </button>
        <ReducedMotionSwitch />
      </div>

      <div className="relative h-52 overflow-hidden rounded-lg border border-border bg-muted/40 p-6">
        {/* Ambient: background life, low enough amplitude that you notice its absence, not its presence. */}
        <div
          aria-hidden="true"
          className="pointer-events-none absolute -right-6 -top-6 size-32 rounded-full bg-primary/10 blur-2xl"
          style={ambient}
        />

        <div className="relative" style={primary}>
          {/* Secondary: the shadow is its own layer, so it can lag the card. */}
          <div aria-hidden="true" className="absolute inset-0 rounded-lg shadow-lg" style={shadow} />
          <div className="relative rounded-lg border border-border bg-card p-4">
            <p className="text-sm font-medium text-card-foreground">Pipeline</p>
            {/* Secondary: contents arrive after the card has landed. */}
            <ul className="mt-3 space-y-1.5">
              {ROWS.map((row, i) => (
                <li
                  key={row}
                  className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
                  style={rowStyle(i)}
                >
                  {row}
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>

      <p className="text-xs text-success">
        Three layers: the card rises (primary), the shadow deepens 50ms behind it and the rows fade in 100ms after it
        lands (secondary), the background drifts a few pixels (ambient). Reserve this for a rare hero moment — reduced
        motion keeps every end state and drops the travel.
      </p>
    </div>
  );
}
```

## References

- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [LottieFiles — quality checklist](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/reference/quality-checklist.md)
- [MDN — box-shadow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/box-shadow)
