# Never scale(0)

**NEVER** · **ID:** `animations-emil-no-scale-zero` · **Category:** animations
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-emil-no-scale-zero

> NEVER: Enter from `scale(0)` — nothing in the real world appears from a point. Start from `scale(0.9–0.97)` (0.96 is a safe popover/menu default) plus `opacity: 0`. Equally never enter on opacity alone: without a transform the element materializes instead of arriving.

Enter from scale(0.9–0.97) with opacity, never from scale(0) and never from opacity alone

scale(0) claims the element had no size a moment ago — it grows out of a mathematical point, which no physical object does. Start somewhere between 0.9 and 0.97 (0.96 is a safe default for popovers and menus) and let opacity carry the rest of the appearance. The opposite failure is just as common: a pure opacity fade with no transform at all, which gives the element no body and no origin, so it materializes rather than arrives. This is about HOW SMALL the motion starts; animations-correct-transform-origin is about WHERE it starts — a popover can have a perfect trigger-anchored origin and still be wrong because it scales up from 0.

## Rule snippet

```tsx
@keyframes enter { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
```

## Bad — do not do this

`animations-emil-no-scale-zero-bad`

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

/**
 * Bad: two ways to break physicality.
 *
 * Left popover enters from `scale(0)` — it materializes from a single point,
 * which nothing in the real world does. Right popover has no transform at all:
 * a pure opacity fade, so it has no origin, no direction, no body.
 */
export function EmilNoScaleZeroBad() {
  const [open, setOpen] = useState(false);
  const frame = useRef(0);

  useEffect(() => {
    frame.current = requestAnimationFrame(() => setOpen(true));
    return () => cancelAnimationFrame(frame.current);
  }, []);

  const replay = () => {
    setOpen(false);
    frame.current = requestAnimationFrame(() =>
      requestAnimationFrame(() => setOpen(true))
    );
  };

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

      <div className="grid grid-cols-2 gap-3">
        <div className="rounded-lg bg-muted p-3">
          <p className="text-[11px] text-muted-foreground mb-2">scale(0)</p>
          <div
            className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
            style={{
              transformOrigin: 'top left',
              transition: open
                ? 'transform 220ms cubic-bezier(0.23, 1, 0.32, 1), opacity 220ms cubic-bezier(0.23, 1, 0.32, 1)'
                : 'none',
              transform: open ? 'scale(1)' : 'scale(0)',
              opacity: open ? 1 : 0,
            }}
          >
            Copy link
          </div>
        </div>

        <div className="rounded-lg bg-muted p-3">
          <p className="text-[11px] text-muted-foreground mb-2">opacity only</p>
          <div
            className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
            style={{
              transition: open ? 'opacity 220ms cubic-bezier(0.23, 1, 0.32, 1)' : 'none',
              opacity: open ? 1 : 0,
            }}
          >
            Copy link
          </div>
        </div>
      </div>

      <p className="text-xs text-destructive">
        Left grows out of a single point (nothing appears from nothing). Right has no transform at all, so it has no
        body — it just materializes.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-no-scale-zero-good`

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

/**
 * Good: identical duration (220ms) and easing to the bad panel. The only change
 * is the starting scale — 0.96 instead of 0 (and instead of no transform at all).
 * The popover was already "there", it just settles into place.
 */
export function EmilNoScaleZeroGood() {
  const [open, setOpen] = useState(false);
  const frame = useRef(0);
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    frame.current = requestAnimationFrame(() => setOpen(true));
    return () => cancelAnimationFrame(frame.current);
  }, []);

  const replay = () => {
    setOpen(false);
    frame.current = requestAnimationFrame(() =>
      requestAnimationFrame(() => setOpen(true))
    );
  };

  const enter = reduced
    ? {
        transition: open ? 'opacity 150ms linear' : 'none',
        transform: 'none',
      }
    : {
        transition: open
          ? 'transform 220ms cubic-bezier(0.23, 1, 0.32, 1), opacity 220ms cubic-bezier(0.23, 1, 0.32, 1)'
          : 'none',
        transform: open ? 'scale(1)' : 'scale(0.96)',
      };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={replay}
          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
        </button>
        <ReducedMotionSwitch />
      </div>

      <div className="grid grid-cols-2 gap-3">
        <div className="rounded-lg bg-muted p-3">
          <p className="text-[11px] text-muted-foreground mb-2">scale(0.96) + opacity</p>
          <div
            className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
            style={{ ...enter, transformOrigin: 'top left', opacity: open ? 1 : 0 }}
          >
            Copy link
          </div>
        </div>

        <div className="rounded-lg bg-muted p-3">
          <p className="text-[11px] text-muted-foreground mb-2">scale(0.96) + opacity</p>
          <div
            className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
            style={{ ...enter, transformOrigin: 'top left', opacity: open ? 1 : 0 }}
          >
            Copy link
          </div>
        </div>
      </div>

      <p className="text-xs text-success">
        Same 220ms, same curve — only the starting scale changed. 0.96 reads as an object settling in, not one being
        born. Reduced motion drops the transform and keeps a plain fade.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — scale](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scale)
- [MDN — transform-origin](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform-origin)
