# Motion Cohesion and Personality

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

> SHOULD: Define duration and easing once as motion tokens and spend them everywhere, matching the curve to the product's personality (crisp and bounce-free for a dashboard, slower and bouncier for a playful app) rather than letting each component be animated to whoever built it's taste.

Match motion to the component's personality and to the rest of the product — one shared motion language, not per-component taste

There is no universally correct curve — there is a curve that is correct for this product. A monitoring dashboard wants short, ease-out, bounce-free motion that gets out of the way; a playful consumer app can legitimately be slower and bouncier. What is never right is a surface where each element was animated by whoever happened to build it: one row bouncing, one crawling linearly, one snapping. Each may be defensible alone, but together they make the product feel assembled rather than designed. Define motion tokens (duration + easing) once and spend them everywhere.

## Bad — do not do this

`animations-emil-motion-cohesion-bad`

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

// Three rows in the same analytics dashboard, each animated by whoever
// happened to build it. No shared vocabulary.
const ROWS = [
  { label: 'Requests', motion: 'transform 700ms cubic-bezier(0.34, 2.4, 0.5, 1), opacity 700ms ease' },
  { label: 'Errors', motion: 'transform 900ms linear, opacity 900ms linear' },
  { label: 'Latency', motion: 'transform 60ms ease-in, opacity 60ms ease-in' },
];

/**
 * Bad: a crisp, professional dashboard where one row bounces like a game, one
 * crawls linearly, and one snaps. Each may be defensible alone; together they
 * make the product feel assembled from parts.
 */
export function EmilMotionCohesionBad() {
  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 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Replay dashboard
      </button>

      <div className="space-y-2 rounded-lg bg-muted p-3">
        {ROWS.map((row) => (
          <div
            key={row.label}
            className="rounded-md border border-border bg-card px-3 py-2 text-sm text-card-foreground"
            style={{
              transition: entered ? row.motion : 'none',
              transform: entered ? 'translateX(0)' : 'translateX(-24px)',
              opacity: entered ? 1 : 0,
            }}
          >
            {row.label}
          </div>
        ))}
      </div>

      <p className="text-xs text-error">
        Three easings, three durations, three personalities in one dashboard — the motion has no author
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-motion-cohesion-good`

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

const ROWS = ['Requests', 'Errors', 'Latency'];

// One motion token for the whole surface. A monitoring dashboard's personality
// is "crisp and out of the way", so: short, ease-out, no bounce.
const DASHBOARD_MOTION =
  'transform 180ms cubic-bezier(0.23, 1, 0.32, 1), opacity 180ms cubic-bezier(0.23, 1, 0.32, 1)';

/**
 * Good: every row speaks the same motion language, and that language matches
 * what this product is. A playful consumer app would legitimately pick a
 * bouncier, slower token — cohesion is about matching the personality, not
 * about one universally correct curve.
 */
export function EmilMotionCohesionGood() {
  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))
    );
  };

  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 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Replay dashboard
      </button>

      <div className="space-y-2 rounded-lg bg-muted p-3">
        {ROWS.map((label, i) => (
          <div
            key={label}
            className="rounded-md border border-border bg-card px-3 py-2 text-sm text-card-foreground"
            style={{
              transition: entered
                ? reduced
                  ? 'opacity 150ms linear'
                  : DASHBOARD_MOTION
                : 'none',
              transitionDelay: entered && !reduced ? `${i * 40}ms` : '0ms',
              transform: reduced ? 'none' : entered ? 'translateX(0)' : 'translateX(-24px)',
              opacity: entered ? 1 : 0,
            }}
          >
            {label}
          </div>
        ))}
      </div>

      <p className="text-xs text-success">
        One shared token (180ms, ease-out, no bounce) — crisp, like the product it belongs to
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [emilkowalski.com](https://emilkowalski.com/)
