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.
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 · LayoutRecalculates the page every frame. Re-measures the page and shoves the neighbours. Use transform: scale(), or grid-template-rows 0fr → 1fr for a collapse.
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: allwatches every style change. Tailwind's baretransitionclass compiles to the same thing; usetransition-transformortransition-[opacity,transform]. will-changeis a targeted fix, not a sprinkle. Addwill-change: transformonly 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; thex: 100shorthand 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.
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 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%.
transition: transform 200ms var(--ease-out), opacity 450ms var(--ease-in-ish);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%.
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 withtransition={{ duration: 2 }}. Scope any!importantspeed hack to the one component, and never ship it.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.Use a real phone
Connect it over USB and inspect it (Safari's Develop menu for iPhone,chrome://inspectfor Android). Desktop throttling doesn't model a weak graphics chip.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.
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?
In the skill
The nodes your agent reads when it does this work.
- Animate only transform and opacityOnly animate transform and opacity. They are GPU-accelerated. Everything else forces layout.
motion/transform-opacity-only - Performance disciplineFast UI is work you don't do. Name transition properties (bare Tailwind `transition` is `all`), composite-only animation, will-change as a targeted fix, virtualize past ~50 rows, keep frames out of React state, reserve space for everything async, preload critical and lazy the rest, pause off-screen, pre-render content, mute transitions during theme swaps.
motion/performance-discipline - `prefers-reduced-motion`The one accessibility rule everyone skips. Disable large translations/scales, not all motion.
motion/prefers-reduced-motion - Debugging animationsSlow-motion playback, frame-by-frame inspection, real-device testing. The three tools.
motion/debugging-animations
Connected lessons
What this lesson builds on and leads to, across every course.