Skip to content
ExamsLibraryFor agentsConnect your agent

Smooth, kind and checked

intermediate·10 min·Markdown for agents

What this lesson teaches

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

  1. 1Cheap properties only
  2. 2The rest of the discipline
  3. 3Reduced motion
  4. 4Debugging: watch it slowly

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.

width / height · Layout

Recalculates the page every frame. Re-measures the page and shoves the neighbours. Use transform: scale(), or grid-template-rows 0fr → 1fr for a collapse.

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

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

✓ After

.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 : people with vestibular conditions, for whom sliding and zooming cause real nausea, and others who simply prefer less. Try each setting here.

No travel, a 150ms fade. Still explains the change
Notifications
Email me when someone mentions me or assigns me an issue.
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.

@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%.

Share link
acme.app/s/q3-plan
transition: transform 200ms var(--ease-out), opacity 450ms var(--ease-in-ish);
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%.

  1. 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.
  2. 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.
  3. 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.
  4. 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· for your agent
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.

Quick check

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?

What this lesson builds on and leads to, across every course.

  • Routing the job
  • Words matter
  • Briefs that work
  • Sticky headers and launch-ready pages
  • The bundle is design