Interface Performance
Fast UI is mostly work you do not do. Do not render nodes nobody can see, do not ask the browser to watch properties that never change, do not animate anything the compositor cannot own, do not fetch at request time what you could build once, and do not move the layout after the user has started reading it. Almost all jank is self-inflicted, and the fix is almost always deleting or deferring work rather than adding cleverness. This skill is entered by a measurement, not by a feeling: if a stopwatch, a profile or a Web Vitals number says it is slow, you are in the right place — if it merely feels wrong, the duration and the easing are motion's to fix and nothing here will help. Never optimise before profiling the actual device.
Adopt the project's existing performance tooling before adding any. If it already has a virtualizer, an image component, a bundle analyzer or a Vitals reporter, use that one — a second virtualization library is worse than an unvirtualized list, because now two things measure the same rows. Check the styling system too: a Tailwind project needs the arbitrary-value transition form, not a hand-written stylesheet beside it. If the framework ships a route-level image or font primitive, it already handles preloading, sizing and format negotiation better than a hand-rolled <img>.
| Topic | Reference |
|---|---|
| A number to hold the work to | Open references/budgets.md when you need the target to measure against — frame time, Web Vitals thresholds, list sizes, image and bundle weights. |
| A symptom with no obvious cause | Open references/diagnosis.md when something janks and you do not yet know why — it maps each symptom to the panel, the setting, and what the trace looks like when you have found it. |
Core Principles
Name the properties that transition; never
transition: all. Withall, every style change becomes a candidate animation — the browser tracks the whole style object, properties you never meant to tween start tweening, and the engine loses the optimisations a narrow, known list allows. Writetransition-property: transform, opacity. Tailwind's baretransitioncompiles totransition-property: all; usetransition-transformortransition-[opacity,filter]. Exception: none — a Tier 0 prohibition; the shorthand's convenience never covers its cost.Animate only what the compositor owns.
transform,opacity,filterandclip-pathskip layout and paint. Animatingheight,width,padding,margin,toporleftruns the full pipeline on every frame. Expand panels withtransform: scaleY()or a clip, or measure once and animate the transform. Exception:filter: blur()is compositor-owned but genuinely expensive — keep it under20pxand off frequently-animating elements, especially in Safari.Reserve
will-changefor a stutter you have observed. The browser creates the compositor layer when motion begins, and that just-in-time promotion occasionally eats the opening frame;will-change: transformrequests it up front and fixes exactly that. It does nothing for paint- or layout-bound properties, and each layer holds real GPU memory. Exception: remove it after the animation on any element that exists in quantity — 200 permanently promoted rows cost more than the hitch they prevented.Virtualize above roughly
50rows. Below that the DOM is cheaper than the bookkeeping; above it, scroll cost grows with total rows rather than visible ones. Use the project's virtualizer, give it aestimateSizethat is close, and measure real rows rather than trusting the estimate. Exception: content that must be findable with the browser's ownCtrl+F, printable, or crawlable — paginate instead of virtualizing.Keep per-frame values out of the render cycle. A
useStatewrite per frame is a re-render storm at pointer speed. Drive frame values through a ref and write to the DOM directly insiderequestAnimationFrame. Exception: a value that changes once per interaction rather than once per frame belongs in state, where it is easier to reason about.CSS custom properties inherit, so never animate one on a parent (Emil Kowalski). Setting
--swipe-amounton a container recalculates styles for every descendant; in a drawer this starts dropping frames at around20items. Writeelement.style.transformon the one element that moves. Exception: a variable set once at the end of an interaction rather than per frame is fine anywhere.Framer Motion's
x,yandscaleshorthands are not hardware-accelerated (Emil Kowalski) — they runrequestAnimationFrameon the main thread, so they drop frames precisely when the browser is busy loading. Use the full string:animate={{ transform: "translateX(100px)" }}. Vercel's dashboard tab animation dropped frames during page loads until it moved to CSS. The general rule follows: CSS for predetermined animation, JS for dynamic and interruptible animation. Exception: verify it in a profile on your own version before rewriting a working component — if the shorthand composites in your build, keep the readable form.Reserve the space before the content arrives. Layout shift is the one performance defect users experience as broken rather than slow. Set explicit dimensions or
aspect-ratioon every image and video, reserve async regions with skeletons, usefont-variant-numeric: tabular-numson numbers that change, never change font weight on hover, and preload above-the-fold fonts so text does not reflow when they land. Exception: content whose height genuinely cannot be known — reserve a minimum and let it grow downward, never above the reading position.Stop work that is off-screen. Pause looping animations, video and polling when the element is not visible, via
IntersectionObserverorcontent-visibility: autoon long off-screen sections. Preload only what is above the fold; everything below getsloading="lazy". Exception:content-visibility: autobreaks in-pageCtrl+Fand anchor scrolling in some engines — keep it off prose.Ship less JavaScript before making JavaScript faster. Split at the route, import icons individually rather than the set, render static content at build time, and apply the replicate-vs-install test before adding a dependency (
pick-libraryowns that call). A component that is 40 lines of CSS does not need a 30 kB library. Exception: correctness-critical dependencies — date/time, currency, internationalisation, sanitisation — where writing your own is the more expensive mistake.Theme switches must not animate. Flipping themes with transitions live makes every colour tween independently, which reads as a smear. Add a
no-transitionsclass, swap the theme attribute, remove the class after a doublerequestAnimationFrame. Exception: a deliberate designed crossfade — one animation on one overlay, not every property on every node.
Smells and Fixes
| Smell | Fix |
|---|---|
transition: all or Tailwind's bare transition |
Name the properties: transition-[transform,opacity] |
height animated to expand a panel |
transform: scaleY(), a clip, or measure once and animate the transform |
will-change on every card "for performance" |
Remove; add it back only where a profile showed a first-frame hitch |
blur(40px) on a moving element |
Under 20px, or move the blur to a static parent |
| 2,000-row table with choppy scroll | Virtualize |
useState inside a pointermove or scroll handler |
Ref plus direct DOM write inside requestAnimationFrame |
setProperty('--x') on a list container per frame |
element.style.transform on the single moving element |
<motion.div animate={{ x }}> in a busy view |
animate={{ transform: "translateX(100px)" }}, or CSS |
<img> with no width/height or aspect-ratio |
Set both; the layout shift is the defect, not the load time |
| Fonts loaded without preload | <link rel="preload" as="font" crossorigin>; check a cold, throttled load |
| Whole icon set imported for four icons | Per-icon imports; re-read the analyzer output |
Output Format
Report every finding with its measurement attached: what was measured → on what device and throttle → the number before → the cause in the trace → the fix → the number after. A finding without a before-and-after number is an opinion, and it will be argued with. Rank by user-visible cost: layout shift and input delay first, then dropped frames during interaction, then load weight.
Checklist
- No
transition: alland no bare Tailwindtransition - Animations touch only
transform,opacity,filter,clip-path; no blur above20pxon moving elements -
will-changeonly where a profile showed a first-frame stutter, and removed afterwards - Lists past ~`50` rows are virtualized with one virtualizer
- No per-frame
useState; no per-frame custom-property writes on a parent - Framer Motion shorthands checked in a profile, or replaced with the transform string
- Every image and video has dimensions or
aspect-ratio; async regions have skeletons - Changing numbers use
tabular-nums; hover never changes font weight - Above-fold fonts and images preloaded; everything below is lazy; off-screen work paused
- Static content generated at build time; bundle checked after adding any dependency
- Theme switching disables transitions for the swap
- Profiled on a mid-range device or with CPU throttling, not on the development machine