# Never ease-in on UI

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

> NEVER: Reach for `ease-in` on UI motion: it barely moves during the first ~100ms the user is watching, so a 200ms `ease-in` reads slower than a 200ms `ease-out`. Default both entrances and exits to `ease-out`; `ease-in-out` for on-screen morphs, `ease` for hover/color, `linear` for constant motion. (This is the strict Emil position; other sources here allow `ease-in` on exits — pick one stance per product and apply it consistently.)

Default to ease-out for enters and exits — ease-in stalls the frames the user is actually watching

Perceived speed is decided in the first ~100ms of a transition. ease-in spends those frames barely moving, so a 200ms ease-in reads as slower than a 200ms ease-out even though the stopwatch disagrees. Emil's decision order: enter/exit → ease-out, on-screen morph → ease-in-out, hover/color → ease, constant motion → linear. Note that this is a real disagreement in the field — other sources in this corpus (see "Easing" and "Timing") permit ease-in specifically for exits, on the argument that a departing element may accelerate away; the value of holding both is seeing the tradeoff illustrated rather than asserted.

## Bad — do not do this

`animations-emil-no-ease-in-bad`

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

/**
 * Bad: the panel enters with `ease-in` — it creeps for the first ~120ms,
 * which is exactly the moment the user is looking at it.
 */
export function EmilNoEaseInBad() {
  const [entered, setEntered] = useState(false);
  const frame = useRef(0);

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

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

  return (
    <div className="w-full max-w-sm 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 entrance
      </button>

      <div className="h-24 rounded-lg bg-muted p-3 overflow-hidden">
        <div
          className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
          style={{
            // ease-in: slow at the start, fast at the end. The delay lands on
            // the exact frames the user is watching.
            transition: entered
              ? 'transform 260ms cubic-bezier(0.42, 0, 1, 1), opacity 260ms cubic-bezier(0.42, 0, 1, 1)'
              : 'none',
            transform: entered ? 'translateY(0)' : 'translateY(-28px)',
            opacity: entered ? 1 : 0,
          }}
        >
          Deploy finished
        </div>
      </div>

      <p className="text-xs text-error">
        ease-in (260ms): the panel hesitates before it moves, so the interface reads as laggy
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-no-ease-in-good`

```tsx
import { useEffect, useRef, useState } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';

/**
 * Good: the same 260ms, but `ease-out`. The element covers most of the
 * distance in the first frames, so it feels faster than the ease-in version
 * even though the clock time is identical.
 */
export function EmilNoEaseInGood() {
  const [entered, setEntered] = useState(false);
  const frame = useRef(0);
  const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');

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

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

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

  return (
    <div className="w-full max-w-sm 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 entrance
      </button>

      <div className="h-24 rounded-lg bg-muted p-3 overflow-hidden">
        <div
          className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
          style={{ ...motion, opacity: entered ? 1 : 0 }}
        >
          Deploy finished
        </div>
      </div>

      <p className="text-xs text-success">
        ease-out (260ms, same duration): it commits immediately, so it feels faster. Reduced motion gets a plain fade.
      </p>
    </div>
  );
}
```

## References

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