Performance discipline
Fast 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.
Raw markdown On GitHubreferences/motion/performance-discipline.md
Taught in
Most jank is self-inflicted: DOM nodes nobody can see, properties the browser has to watch that never change, animation the GPU can't composite, content fetched at request time that could have been built, layout that moves after the user started reading. The fix is usually deleting or deferring work, not adding cleverness. transform-opacity-only states the property rule; this node is the rest of the discipline.
The budget and the tiers
60fps leaves 16.7ms per frame; a 120Hz screen leaves 8ms — design against 8. One layout pass on a complex page can exceed 100ms, a dozen dropped frames at once.
| Tier | Properties | Cost |
|---|---|---|
| Composite | transform, opacity (and, in current Chrome/Firefox, filter, clip-path, background-color) | Cheapest |
| Paint | box-shadow, border-radius, color | A redraw every frame |
| Layout | width, height, padding, margin, top/left, border-width | Re-measures, and moves the neighbours |
Swap down a tier: size → scale(), position → translate(), display/visibility → opacity, box-shadow → filter: drop-shadow(). Acceleration is something the browser may grant; property choice is what you control, so keep translateY(100%) for unknown-size elements even where a browser declines to accelerate percentages.
Transitions and compositing
- Name the properties.
transition: allmakes every style change a candidate animation and forfeits the engine's optimizations. Tailwind's baretransitionclass compiles totransition-property: all— the same bug in disguise; usetransition-transformortransition-[opacity,scale]. - Composite-only.
transform,opacity,filter,clip-path. Neverheight,width,padding,margin,top/left. Keep animatedblur()under 20px (Safari suffers most). will-changeis a targeted fix. Browsers promote layers just in time, and that occasionally eats the opening frame — a 1px hitch as motion starts. Applywill-change: transform(ortransform, opacity) to the animating element after you observe the hitch. It does nothing for layout or paint properties,will-change: alldefeats the hint, and every promoted layer holds GPU memory.- Never animate a CSS variable on a shared parent — every descendant recalculates style. Set the property on the moving element. Vaul's drag, written as a
--swipe-amountvariable read bytranslateY(), turned laggy once a drawer held ~20 items. Suspect this whenever atransformanimation degrades as content grows.
Rendering work
- Virtualize any list that can pass ~50 items or grow unbounded (TanStack Virtual or equivalent). Render only what is visible.
- Frames never go through React state. A per-frame
setStateis a re-render storm. Write transforms through refs, WAAPI, or the library's own loop. In Motion the string formanimate={{ transform: "translateX(100px)" }}is hardware-accelerated; thex: 100shorthand runs on the main thread. - CSS/WAAPI for predetermined motion (stays smooth under load); a JS loop only for dynamic, gesture-driven, or spring values. Vercel's dashboard tab indicator, a Motion shared-layout animation, dropped frames because the next page was loading at that moment; a CSS animation fixed it.
Diagnosis order
Name the cause before changing code. In order of likelihood: (1) a non-composite property animates (layout first, then box-shadow, border-radius); (2) the jank coincides with navigation, fetching or hydration; (3) React re-renders per frame; (4) it worsens as content grows; (5) a CSS variable animates, directly or through Motion shorthands; (6) animated blur() above 20px; (7) only then will-change. Confirm with a Performance recording on a mid-range phone over remote debugging; desktop CPU throttling doesn't model a weak GPU or a small memory budget.
- Pause off-screen work: looping animations, video, timers —
IntersectionObserveroranimation-play-state, plusvisibilitychangefor hidden tabs.
Loading and layout
- Reserve space for everything asynchronous. Images and video declare dimensions or
aspect-ratio; skeletons match the loaded box; empty states are sized to the filled state; changing numbers usetabular-nums; hover never changes font weight. - Preload fonts and above-the-fold images; lazy-load below the fold. A font without preload is a visible reflow on every cold load.
- Pre-render content surfaces (blog, docs, changelog) at build time with revalidation. Never fetch them at request time.
- Mute transitions during a theme switch: add a
no-transitionsclass, swap the theme, remove the class after a doublerequestAnimationFrame. Otherwise every color on the page tweens independently in a ragged cascade.
Expanding panels
Animating height triggers layout every frame. Use grid-template-rows: 0fr → 1fr, a clip, or measure once and animate transform — see clip-path-tricks.
When to apply
"It's slow", "it stutters", "it jumps", any list over ~50 rows, any diff that adds a transition, a font, a hero image, or a theme toggle. Slowness is measured, not judged: if the complaint is "feels wrong" rather than "drops frames", route to easing-curves and duration-table instead.
Gotcha
will-change sprinkled on every card "for performance" is the most common cargo cult in generated CSS. It costs a compositor layer per card and fixes stutters the cards never had. Remove it and add it back only where a first-frame hitch is reproducible.
Sources
- Emil Kowalski's design-engineering practice on performance, distilled by HKTITAN.
- Emil Kowalski, skills (MIT, github.com/emilkowalski/skills, a81dbca): skills/animation-performance/SKILL.md (frame budget, tiers, swaps, Vaul and Vercel cases, diagnosis order).
- Related: transform-opacity-only, debugging-animations, empty-loading-states, marketing-surface-rules.
motionperformancejankvirtualizationlayout-shift