# Smooth, kind and checked

> Keep animation smooth on slow phones, respect people who ask for less motion, and debug or review motion before it ships.

Module: Motion · Level: intermediate · 10 min · https://school.agents.org.in/design/motion/performance-and-review

Tap a property you might animate. Some cost the browser almost nothing per frame. Others make it recalculate the whole page, sixty times a second.

*[Interactive demo: What each property costs per frame, and what to animate instead.]*

A screen redraws 60 times a second (16.7ms per frame), and a 120Hz phone gives you 8ms. Design against 8. One layout pass on a busy page can take longer than that by itself.

## Cheap properties only

There are three tiers. **Composite**: the browser hands a finished picture to the graphics chip and slides it around; that's `transform` and `opacity` (and, in current browsers, `filter` and `clip-path`). **Paint**: it redraws pixels every frame, like `box-shadow` or `color`. **Layout**: it re-measures everything and moves the neighbours, like `width`, `height`, `top`, `margin`.

**Animate transform and opacity; swap everything else down a tier.** Size becomes `scale()`, position becomes `translate()`, showing and hiding becomes `opacity`, a growing shadow becomes the opacity of a layer that already has it. A collapsing panel uses `grid-template-rows: 0fr → 1fr` rather than animating `height`.

  **Before (avoid):**

```css
.card {
  transition: all 300ms;
}
.card:hover {
  width: 320px;
  box-shadow: 0 12px 32px #0002;
}
```

  **After (do this):**

```css
.card {
  transition: transform 200ms
    var(--ease-out);
}
.card:hover {
  transform: translateY(-2px);
}
```

## The rest of the discipline

Most jank is self-inflicted. From Emil Kowalski's performance notes:

- **Name the properties.** `transition: all` watches every style change. Tailwind's bare `transition` class compiles to the same thing; use `transition-transform` or `transition-[opacity,transform]`.
- **`will-change` is a targeted fix, not a sprinkle.** Add `will-change: transform` only to an element where you've seen a first-frame hitch. On every card, it eats graphics memory and fixes nothing.
- **Never animate a CSS variable on a shared parent**; every child recalculates. Vaul's drawer got laggy at ~20 items for exactly this reason.
- **Frames never go through React state.** Write per-frame values through refs, the Web Animations API or the library's own loop. In Motion, the string `transform: "translateX(100px)"` is hardware-accelerated; the `x: 100` shorthand runs on the main thread, which only hurts when the page is busy.
- **Use CSS for planned motion** that has to stay smooth while the page loads. Vercel's dashboard tab indicator dropped frames during navigation until a CSS animation replaced a JavaScript one.
- **Virtualize lists past ~50 rows**, pause off-screen loops, reserve space for images and loading content, and mute all transitions for a frame while switching themes so colors don't tween in a ragged cascade.

When it stutters, name the cause before changing code, in this order: a non-composite property, jank that lines up with loading, React re-rendering per frame, slowdown as content grows, an animated CSS variable, a large blur, and only then `will-change`.

## Reduced motion

Some people set their device to reduce motion (A device setting people turn on when movement makes them unwell or distracted. Interfaces should drop slides, zooms and parallax, but keep short fades): people with vestibular conditions, for whom sliding and zooming cause real nausea, and others who simply prefer less. Try each setting here.

*[Interactive demo: Four ways to honour the setting. Only one is right; one is a real bug.]*

Reduced motion doesn't mean "no animation"; it means **no travel**. Drop large slides, zooms, parallax, autoplay and spinning loaders. Keep short opacity fades and color changes, which explain a change without moving anything.

```css
@media (prefers-reduced-motion: reduce) {
  .modal { transform: none; transition: opacity 150ms ease-out; }
}
```

In React with Motion, `useReducedMotion()` lets you set the slide distance to 0 and keep the fade. Some teams take the stricter line and remove the fades too; either way, ship the reduced version with the animation, not later.

*(The worst bug: text that starts at opacity 0 and only fades in under "no-preference".)*

The worst bug is in the demo's last mode. If an entrance starts from `opacity: 0` and the animation only runs when motion is allowed, people who reduce motion never see the content at all. The reduced state is the **finished** state. Test with the setting on.

## Debugging: watch it slowly

Open this popover at full speed. It looks fine. Now try 10%.

*[Interactive demo: A bug that only shows when you slow down. Then apply the fix.]*

At full speed, the eye can't see that the box finishes growing while it's still half see-through, because its scale and opacity run on different curves and times. At quarter speed it's obvious. Almost every "it feels off" bug is visible at 25% and invisible at 100%.

  **Step: Slow it down.** Chrome DevTools has an Animations panel with 10%, 25% and 50% playback, no code changes needed. Or wrap a Motion app in its MotionConfig component with `transition={{ duration: 2 }}`. Scope any `!important` speed hack to the one component, and never ship it.

  **Step: Look frame by frame.** Record in the Performance panel. Frames over 16ms, and layout or paint during an animation, point at the property you're animating.

  **Step: Use a real phone.** Connect it over USB and inspect it (Safari's Develop menu for iPhone, `chrome://inspect` for Android). Desktop throttling doesn't model a weak graphics chip.

  **Step: Record before and after.** A fix to one animation quietly changes others. Record the same interaction twice and compare by moments (the click, the peak, the settle), not milliseconds.

When you review someone else's motion, slow it to 25% before you approve it.

**Make motion fast and kind (paste into your agent):**

```text
Audit the animations in this app. Replace any transition: all (including Tailwind's bare transition class) with named properties. Rewrite animations of width, height, top, left, margin or box-shadow to use transform, opacity, or the opacity of a pre-shadowed layer. Remove blanket will-change. Add a prefers-reduced-motion: reduce block for each animated component that removes translate and scale but keeps a short opacity fade, and make sure no content stays at opacity 0 when motion is reduced. Tell me which animations you checked at 25% speed and what you found.
```

**Check yourself:** With reduced motion on, a hero heading never appears. Its CSS sets opacity: 0 and runs a fade-in only inside @media (prefers-reduced-motion: no-preference). What's the fix?

- [ ] Set every animation to 0ms with !important
- [x] Make the visible, finished state the default and only add the hidden start state when motion is allowed (or keep a short fade under reduce)
- [ ] Remove the heading for those users
- [ ] Use a spinner while the heading loads

*Why:* The reduced state must be the finished state. The start state (opacity: 0) belongs inside the motion-allowed rule, or the reduced path keeps a short fade so the text still arrives.

## In the skill

- https://school.agents.org.in/skill/motion/transform-opacity-only.md
- https://school.agents.org.in/skill/motion/performance-discipline.md
- https://school.agents.org.in/skill/motion/prefers-reduced-motion.md
- https://school.agents.org.in/skill/motion/debugging-animations.md
