# Use Strong Custom Easing Curves

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

> SHOULD: Replace the weak built-in easing keywords with strong custom curves, defined once as tokens rather than hand-rolled per component.

Replace the built-in easing keywords with a strong cubic-bezier for any motion meant to feel deliberate

The keyword `ease-out` is cubic-bezier(0, 0, 0.58, 1) — a shallow curve that, over any real distance, reads as near-linear with a mushy stop. cubic-bezier(0.23, 1, 0.32, 1) reaches roughly 80% of the distance in the first third of the duration and then decelerates, which is what "responsive but composed" looks like. Define the curves once as tokens (ease-out for UI, ease-in-out for on-screen movement, the iOS-like 0.32/0.72/0/1 for drawers) rather than hand-rolling a bezier per component. DIRECT CONFLICT, deliberately kept: ibelick's baseline-ui states the opposite — "NEVER introduce custom easing curves unless explicitly requested". Both are right for their reader, and the axis is DEFAULTS vs MASTERY. ibelick is writing for an agent, where an unprompted cubic-bezier is a guess: a model that invents curves per component produces exactly the incoherent motion that animations-emil-motion-cohesion warns about, so the conservative default — reach for the keyword, do not improvise — is correct for the party that was not asked. Emil is writing for a human tuning motion on purpose, where the keyword is the ceiling you eventually hit: `ease-out` really is cubic-bezier(0, 0, 0.58, 1), and no amount of duration tuning will make it feel composed. So: an agent should not invent a curve unprompted; a designer who has decided the motion matters should reach for a real one — and then ship it as a token, which is what makes it a request rather than an improvisation.

## Rule snippet

```tsx
:root {
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}
```

## Bad — do not do this

`animations-emil-strong-easing-bad`

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

/**
 * Bad: the built-in `ease-out` keyword is cubic-bezier(0, 0, 0.58, 1) — a very
 * shallow curve. Over a long distance it reads as an almost-linear slide with a
 * mushy stop. The motion has no point of view.
 */
export function EmilStrongEasingBad() {
  const [moved, setMoved] = useState(false);
  const frame = useRef(0);

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

  const replay = () => {
    setMoved(false);
    frame.current = requestAnimationFrame(() =>
      requestAnimationFrame(() => setMoved(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
      </button>

      <div className="relative h-16 rounded-lg bg-muted overflow-hidden">
        <div
          className="absolute top-4 left-2 h-8 w-8 rounded-md bg-primary"
          style={{
            // Built-in keyword easing — weak, near-linear.
            transition: moved ? 'transform 500ms ease-out' : 'none',
            transform: moved ? 'translateX(240px)' : 'translateX(0)',
          }}
        />
      </div>

      <p className="text-xs text-error">
        Built-in <code>ease-out</code> = cubic-bezier(0, 0, 0.58, 1) — too weak to read as deliberate motion
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-strong-easing-good`

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

/**
 * Good: a strong custom curve — cubic-bezier(0.23, 1, 0.32, 1). It launches
 * hard and decelerates into place, which is what "responsive but composed"
 * actually looks like. Same distance, same 500ms.
 */
export function EmilStrongEasingGood() {
  const [moved, setMoved] = useState(false);
  const frame = useRef(0);
  const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');

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

  const replay = () => {
    setMoved(false);
    frame.current = requestAnimationFrame(() =>
      requestAnimationFrame(() => setMoved(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
      </button>

      <div className="relative h-16 rounded-lg bg-muted overflow-hidden">
        <div
          className="absolute top-4 left-2 h-8 w-8 rounded-md bg-primary"
          style={{
            transition: reduced
              ? 'none'
              : moved
                ? 'transform 500ms cubic-bezier(0.23, 1, 0.32, 1)'
                : 'none',
            transform: moved ? 'translateX(240px)' : 'translateX(0)',
          }}
        />
      </div>

      <p className="text-xs text-success">
        Strong curve <code>cubic-bezier(0.23, 1, 0.32, 1)</code> — sprints, then settles. Reduced motion snaps instantly.
      </p>
    </div>
  );
}
```

## References

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