Front-end performance is partly real (frames per second, paint cost) and partly perceived (how fast the interface feels regardless of the clock). Both matter. This doc covers the visual and interaction side: keeping motion smooth, holding layout still while content loads, and making the UI feel instant. Server and data-fetch tuning live elsewhere.
No layout shift
Content that arrives late should never push the rest of the page around. A button that jumps as an image loads, or a number column that reflows as digits change, reads as broken even when nothing is wrong.
Reserve the space before the content exists:
- Give images and videos explicit dimensions (or
aspect-ratio) so the box is the right size before a single byte downloads. - Size skeletons to match the real content they stand in for. A skeleton that is taller or shorter than the thing it replaces just moves the shift to swap-in time.
- Use
font-variant-numeric: tabular-numsfor numbers that update in place (timers, counters, prices) so digits keep a fixed width and the column does not breathe.
.media {
aspect-ratio: 16 / 9;
width: 100%;
}
.counter {
font-variant-numeric: tabular-nums;
}
Font format: ship .woff2 on the web and nothing else. Never serve .ttf or .otf to a browser. WOFF2 is the same outlines with Brotli compression built into the format, so it is roughly 30% smaller than WOFF and far smaller than a raw .ttf, and every browser that can run this product supports it. A .ttf in a @font-face stack is almost always a desktop font file someone copied straight into the repository.
Font swap: preload the fonts the first screen needs so the swap from fallback to web font happens before paint, not after. A late swap reflows every line of text it touches.
<link rel="preload" href="/fonts/sans.woff2" as="font" type="font/woff2" crossorigin />
Two more small rules that prevent surprise shifts: do not change font weight on hover (bolder glyphs are wider), and avoid adding or removing borders on state change unless you compensate the size elsewhere.
For repeated video embeds, generate lightweight metadata at build time and key it by source. The component can reserve the exact aspect ratio and render a blurred placeholder before the video is ready, without duplicating dimensions throughout the product.
function StableVideo({ src, ...props }: VideoProps) {
const { aspectRatio, placeholder } = videoMetadata[src];
return (
<div className="media-frame" style={{ aspectRatio }}>
<img className="media-placeholder" src={placeholder} alt="" />
<video src={src} {...props} />
</div>
);
}
.media-frame {
display: grid;
}
.media-frame > * {
grid-area: 1 / 1;
width: 100%;
height: 100%;
object-fit: cover;
}
Keep the placeholder decorative with an empty alt, stack the real media above it, and remove or hide the placeholder once the video can paint. The metadata generator, not individual call sites, remains the source of truth.
Prefer transform and opacity
transform and opacity are the two properties to prefer, because they are the only common ones a browser can change without re-running layout or paint. That makes them the best candidates for the compositor, not a guarantee of it. Whether a given animation actually gets there is the browser's decision per element, and it turns on whether the element is promoted to its own layer, which property is animating, how large an area has to be composited, and what else is painted on or over that element. When the browser does composite it, the frame-by-frame work moves off the main thread. When it declines, you are back on the main thread with everything else, which is why a profile is the only way to confirm it for a specific case.
Animating width, height, padding, margin, top, or left sends the browser back through layout and paint on every frame. On a busy page that is where jank comes from.
/* Re-runs layout every frame */
.panel { transition: height 200ms ease; }
/* Skips layout and paint; the browser can composite this */
.panel { transition: transform 200ms ease, opacity 200ms ease; }
Related rules that keep transitions cheap:
- Never use
transition: all. List the exact properties so nothing animates by accident and the browser does not watch every property for change. - Keep
blur()under 20px. Heavy blur is expensive to paint, especially in Safari, and gets worse on anything that animates frequently. - Avoid animating inherited CSS variables that live high in the tree. A variable change can invalidate styles across many descendants, so on something like a swipe-tracking value, write
transformstraight onto the moving element instead of updating a shared variable on its container. - Use
will-changeonly after measurement identifies a layer-promotion problem. Leaving it on many elements consumes memory and can make performance worse.
Work within the frame budget
At 60Hz, the browser has at most 16.7ms to prepare a frame; higher-refresh displays allow even less time. Application code, framework rendering, style calculation, layout, paint, and composition all share that budget. A smooth isolated demo can still miss frames when the real page is fetching data, rendering a list, or responding to pointer input.
Follow the rendering pipeline when diagnosing motion:
- Check which properties change. Geometry such as
width,height,top, andleftusually adds layout and paint work. - Look for layout reads immediately after writes inside the same frame. That pattern forces the browser to resolve layout synchronously.
- Check whether pointer or scroll updates call React state on every sample and re-render a large subtree. Use an imperative value, ref, or animation-library motion value for transient frame-by-frame data.
- Check the scope of CSS variables and selectors. Updating an inherited variable near the root can recalculate styles across many descendants.
- Inspect large painted areas, filters, shadows, masks, and an excessive number of promoted layers.
- Record again after changing one cause. Do not infer a win from the code shape alone.
Use the browser's Performance panel to capture the real interaction and inspect dropped frames, long tasks, style recalculation, layout, paint, and compositing. CPU throttling helps reveal main-thread pressure, but it does not reproduce a weak mobile GPU. Validate important motion on a representative mid-range phone or comparable device, with realistic content and the surrounding surface active.
Why transforms look better, not just run faster
Frame rate is the usual justification and it is the smaller half of the story. The bigger half is sub-pixel rendering.
Layout is computed in fractions of a pixel, and engines then snap that geometry to whole device pixels when it paints, disagreeing with each other about how they round. The same computed 791.984px box can land on 792 physical pixels in one browser and 791 in another. So a thin element animated by its height or width adds and removes whole pixel rows, and a two-pixel divider growing to three pixels has no intermediate state to show.
A transform is applied after layout, so the element can sit at a fractional offset and the engine can antialias its edge across a pixel row instead of snapping it. A row that is half covered can render at half opacity, which fakes a resolution the display does not have. That is why a scaled hairline glides and a resized one steps.
Two qualifications, both load-bearing:
- The exact behaviour is engine-dependent. Pixel snapping is not specified identically across browsers, and no single normative statement covers all of them, so treat this as the reason to test rather than as a guarantee.
- The difference is invisible at high pixel density, where each CSS pixel covers several device pixels and the steps are too small to see. It is obvious at low density. That is precisely the hardware most likely to be hurt by the animation, and precisely the hardware you are not developing on.
When a layout property is the right answer
A scale transform never re-runs layout, so the text inside a scaled box is not re-wrapped, it is stretched. Line breaks are computed once at the original size and then scaled along with everything else, which is exactly why the transform is cheap. Growing a card from one width to another with scale squashes or stretches its type, and no amount of tuning fixes that.
Chrome adds a wrinkle worth knowing. It re-rasters content when its transform scale changes, so scaled text stays crisp by default. Setting will-change: transform opts the element into a fixed bitmap that is never re-rastered under transform updates, which is faster and stays blurry after the scale. Reach for will-change only when you have measured that the re-raster is the bottleneck.
The one case where will-change is the fix, not a cost. An element that jumps 1px to 2px at the start or end of an animation, seemingly at random and most often in Safari on iOS, is being rounded to whole pixels because it animates on the main thread without its own compositing layer. Add will-change: transform to that element and the jump goes. This is the exception to the measure-first rule above, because the symptom already identifies the cause. Name only the properties that actually change, and keep the list to transform, opacity and filter, which are the ones a compositing layer can help. Declaring will-change on a property the browser cannot composite spends the memory and buys nothing.
So animating a layout property is sometimes correct: when the text has to re-flow to its new width, when the box has to push its siblings, or when a grid track genuinely has to resize. The rule is "prefer transform and opacity, and know what you are giving up", not "never touch anything else".
Where the crossover sits, the point at which layout animation starts costing real frames, depends on the device, the element's position in the tree, and what else is on the page. Measure it on your slowest target. There is no element count that marks the line, and any figure quoted as one is someone else's hardware.
Reading a performance profile
"Record and compare against the frame budget" only helps if you know what you are looking at. Record in a Chromium browser, whose Performance panel gives the frame-level detail described here, and read the recording from the top down.
The frames track is one box per painted frame, coloured by whether the browser made the deadline. White is an idle frame with nothing to do, green is a frame rendered as expected and in time, yellow with a sparse dashed fill is a partially presented frame, and red with a dense solid fill is a dropped one. Hover a frame to see its duration and frame rate. The budget is one display refresh interval, so it shrinks on a high-refresh screen. A solid run of green is the target, not a particular millisecond figure.
The main track is everything that ran on the main thread, drawn as a flame chart with time on the x axis and the call stack on the y. Activity is colour-coded by category, consistently across recordings: yellow is scripting, purple is rendering (style recalculation and layout), and green is painting. Grey bars are system work, usually a wrapper holding a group of the other three, and they are rarely where the answer is. Individual scripts get arbitrary colours purely so you can tell them apart.
Over-budget work is flagged for you. A task longer than 50ms is marked with a red triangle and the part beyond the threshold is shaded red, so you can spot the problem before measuring anything.
Callbacks are labelled by their source. A setTimeout or setInterval callback appears as Timer Fired, which is how you separate scheduled work from the per-frame work around it. Expand it to see what the callback actually did, and how long.
Judge the recording by how much of it is idle, not by the frame rate. A profile that is mostly empty space has headroom for everything else the page will do; a profile with no gaps is at its ceiling even if every frame currently lands on time. A healthy idle share on a fast laptop is far higher than a healthy idle share on a cheap one, so compare a device against its own baseline rather than against a number.
Node count and garbage collection
Removing a DOM node does not free its memory. node.remove() takes the element out of the tree immediately, so it stops rendering and stops affecting layout, but the memory is reclaimed only once nothing references it and the collector next runs. You do not control when that is.
The practical consequence: turn on the memory track and the node count will climb in a staircase even when your cleanup is correct. A rising node count is not by itself a leak. Record for longer and look for the drop. When the collector runs you will see it in the main track as a scripting task named Major GC or similar, followed by the node count falling back to a sane number.
Modern engines split collection across frames rather than stopping the world, so a collection that takes many times the frame budget in total can still cost only one dropped frame. Check the frames track around the collection rather than assuming the task duration translates into visible stutter.
What is a real leak is a node count that climbs and never drops across a long recording, or a heap that ends each collection cycle higher than the last.
For an upper bound to reason against, run Lighthouse's DOM-size audit and take its thresholds from the tool rather than from a figure quoted here. An uncleaned particle emitter blows through any of them in seconds, which is the real argument for tying removal to the animation rather than hoping it keeps up. See particles for the emitter pattern.
Baseline, then treatment
Profiling a single recording tells you what the page costs. It does not tell you what the suspect code costs. Diff two recordings instead.
- Comment out the code you suspect. Record five or six seconds of the animation running without it.
- Re-enable the code. Record again, in a fresh tab, for the same length of time.
- Compare the two profiles: idle proportion, dropped frames in the frames track, and whether any new labelled task appears.
Use a fresh tab for the second recording so accumulated state from the first run is not sitting in the numbers. If the two profiles look the same, the code you were worried about is not the problem, and you can stop optimising it.
What a profile does not tell you
- A smooth recording in one browser says little about the others. Rendering engines differ in how they rasterize, composite and snap to the pixel grid, and they are not equally fast at the same work. Confirm on the engines your users are actually on, even if you record in Chromium because its tooling is better.
- CPU throttling does not slow the GPU. The setting slows script and layout work only, so it leaves composited animation running at full speed and never reproduces the memory pressure, thermal behaviour or GPU limits of a real cheap device. It is a quick sense of scale, not a substitute for hardware.
- Profiling through an iframe is unreliable. An embedded preview mixes the host page's work into your recording and gets in the way of the frame data. Pop the preview into its own tab before recording.
- Fast effects hide their own start. A burst that is over in a few hundred milliseconds shows you nothing useful at normal playback. Enable screenshots in the recording settings and step frame by frame to see the first few frames, which is where entrance bugs live.
Test on real hardware
A device kit is a real requirement for animation work, not a nice-to-have. The minimum useful set is three machines:
- One low-end laptop, the kind with integrated graphics and a low-density screen.
- One budget Android phone, connected over remote debugging so you can profile it directly.
- One iPhone several generations old, to cover the other rendering engine and an older GPU.
Every claim in this doc about steppiness, blur cost and layout-animation crossover resolves differently on those three. If an animation is smooth on all of them, it is smooth. If you have only tested it on a current developer laptop, you have tested nothing.
Perceived performance
How fast software feels is a separate lever from how fast it is, and you can pull it without touching real load time. The best loading state is the one nobody has to interpret, so remove the wait where you can, start safe work early, and keep useful content on screen while the new work finishes.
- Optimistic UI: apply the result of an action immediately and reconcile with the server in the background, rolling back if the write fails. The user sees their change land at once instead of waiting on a round trip.
- Immediate feedback: every press should be acknowledged the instant it happens. A subtle
transform: scale(0.97)on:activeconfirms the interface heard the tap before any work finishes. - Skeletons over spinners: a skeleton that mirrors the final layout reads as "almost there," while a lone spinner reads as "stuck." The skeleton also doubles as the reserved space that prevents layout shift. Reserve it for layouts whose shape is known and stable.
- Keep what the user already had: read from a local cache so a returning view does not flash back to an empty skeleton, and hold stale content on screen while it refreshes rather than clearing it first.
- Use the time you already have: onboarding steps and decision points are free background time, so fetch or prepare the next thing while the user is reading.
- Show progress only when it is honest: a progress indicator earns its place when it sets a reliable expectation, and costs you trust when it stalls partway.
- Motion speed shapes perception: a snappier animation makes the whole app feel faster even when the underlying work is identical. A faster spinner makes a load feel quicker. Keep UI animations under 300ms, and prefer
ease-outso the user sees movement begin immediately rather than waiting through a slow start.
type Todo = { id: string; complete: boolean };
export async function optimisticallyComplete(
todo: Todo,
render: (next: Todo) => void,
persist: (next: Todo) => Promise<void>,
) {
const optimistic = { ...todo, complete: true };
render(optimistic);
try {
await persist(optimistic);
} catch (error) {
render(todo);
throw error;
}
}
Optimism is appropriate only when the likely result is predictable and the rollback is understandable. Payments, destructive changes, and permission-sensitive actions generally need stronger confirmation of server truth.
The goal is to remove dead time from the user's experience, not necessarily from the machine's.
Virtualization for long lists
Do not render thousands of DOM nodes when only a handful are on screen. Virtualization renders just the visible rows plus a small buffer, recycling them as the user scrolls. This keeps the node count, memory, and scroll cost flat no matter how long the underlying data is.
import { useVirtualizer } from "@tanstack/react-virtual";
function Rows({ items }) {
const scrollRef = useRef(null);
const rows = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
});
return (
<div ref={scrollRef} style={{ height: 400, overflow: "auto" }}>
<div style={{ height: rows.getTotalSize(), position: "relative" }}>
{rows.getVirtualItems().map((row) => (
<div
key={row.key}
style={{ position: "absolute", top: row.start, height: row.size }}
>
{items[row.index]}
</div>
))}
</div>
</div>
);
}
Reach for it once a list grows past a few hundred rows, or whenever scroll starts to stutter.
Preloading and prefetch on intent
Start fetching what the user is about to need before they commit. A hover or a focus on a link is a strong signal of intent: by the time the click lands, the route or data can already be in flight or done.
- Prefetch a route's code and data on
mouseenter/focusof its link, so navigation feels instant. - Preload above-the-fold images and first-screen fonts so the hero paints without a flash.
- For a sequential image experience, load only the visible window and explicitly preload the next item with
<link rel="preload" as="image">or anImageobject. Do not depend on a visually hidden element as the preload contract. - Pause off-screen work. Use
IntersectionObserverto stop looping animations, video, or polling when an element scrolls out of view, then resume on the way back in.
const io = new IntersectionObserver(([entry]) => {
entry.isIntersecting ? resume() : pause();
});
io.observe(node);
Prefetch on intent, not on page load, so you are not paying to fetch things the user never reaches.
Choose CSS or JavaScript by behavior
CSS is not automatically off the main thread, and JavaScript is not automatically slow. A CSS animation or transition can run off the main thread, but only when the property it animates allows it. The same rule from "prefer transform and opacity" above applies: a transition on transform or opacity is a candidate for the compositor, while a transition on width, height, top or left sends the browser back through layout and paint every frame, on the main thread, exactly as a JavaScript loop would. Writing that motion in CSS buys nothing. What makes an animation cheap is the property it touches and how much work it does each frame, not the syntax that started it.
Where CSS does hold a real advantage is once the animation is composited. It then keeps advancing while the main thread is contended by route loading, script parsing or painting. JavaScript loops driven by requestAnimationFrame are on the main thread by construction, so the same contention drops their frames whatever they animate.
The split:
- CSS for predetermined state changes such as hovers, toggles, and simple entrances, where the browser can own the timeline.
- JavaScript / springs for dynamic motion such as drags, gestures, and targets that change while moving. Keep per-frame work outside framework render cycles and write only the values that must change.
Inside CSS, prefer transitions over keyframes for anything triggered rapidly. Transitions can be interrupted and retargeted from their current value, while keyframes restart from zero, which produces a visible snap when the user fires the same action twice quickly.
WAAPI for programmatic CSS animation
When you need to drive an animation from JavaScript while letting the browser own its timeline, use the Web Animations API. It goes through the same engine as a CSS animation, so it is a candidate for the compositor on exactly the same terms and under the same conditions described above, and it is interruptible in the same way. What it adds is control from code with no library: the returned Animation can be paused, reversed, cancelled, or retargeted without a framework render.
node.animate(
[{ clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0 0)" }],
{
duration: 600,
fill: "forwards",
easing: "cubic-bezier(0.77, 0, 0.175, 1)",
},
);
This is the right tool when a value is computed at runtime (a reveal tied to scroll position, an animation whose target depends on state) but the motion itself does not need a full spring engine.
Animation libraries do not guarantee a fast path
An API that makes transform and opacity convenient does not prove where the work runs, and the caveat is about specific props rather than about the library. Motion (formerly Framer Motion) is hybrid rather than built on the Web Animations API. It hands an animation to WAAPI only when a set of conditions all hold: the animated property is one it treats as accelerated (opacity, clipPath, filter, transform), the target is a plain HTML element in the same timing context as the main window, there is no per-frame onUpdate callback to read values from, and the animation is not an inertia type, not a mirrored repeat and has no repeat delay. Anything that fails one of those runs on Motion's own frameloop, on the main thread.
Its individual-transform shorthand (x, y, scale, rotate) fails the first condition. Those props are a Motion feature that CSS and WAAPI do not have, and it implements them by composing every one that is set into a single transform string it writes each frame from that main-thread loop. The name x is never in the accelerated set, so an x animation never takes the WAAPI path, even though the string it ends up producing is a transform.
Pass the full transform string instead, so the animation is eligible for the accelerated path:
{/* Convenient, but driven from Motion's main-thread loop; stutters under load */}
<motion.div animate={{ x: 100 }} />
{/* Eligible for the accelerated path; stays smooth while the page is busy */}
<motion.div animate={{ transform: "translateX(100px)" }} />
The difference only shows up under load, which is exactly when a dashboard or feed animation is most likely to be running. If a library-driven animation is janking during page transitions, combining the axes into one transform string (or moving to a pure CSS animation) is usually the fix.
A motion value solves a different half of the same problem. Writing a pointer sample to useMotionValue updates the animated style without a framework render on every frame, which is worth doing on any gesture, but it does not change where the animation runs: the value is still written from Motion's main-thread loop. Treat it as a way to cut render cost, not as a route to the compositor.
Then measure again, because the prop is not the only thing that can miss the budget. If the trace still shows dropped frames after the change, look at the surrounding framework renders, the layout reads in your event handlers, the size of the layer, and the paint cost, rather than swapping shorthand syntax again and assuming the browser switched execution paths.
Time-driven versus frame-driven libraries
Ask how a library advances its animations before you trust a duration. A library whose ticker adds a fixed amount of progress per frame runs long whenever the main thread stalls: drop half the frames and the animation takes twice its nominal duration to finish. A library that advances by elapsed time since the last tick lands on schedule regardless, because it skips ahead instead of falling behind.
This matters most for cleanup. Any code shaped like "the animation is 600ms, so remove the node after 700ms" is a guess about frame delivery, and it is the guess that fails on exactly the slow device the cleanup was written to protect. Key removal to the event that says the animation actually ended.
node.addEventListener("animationend", () => node.remove(), { once: true });
node.addEventListener("animationcancel", () => node.remove(), { once: true });
CSS animations and the Web Animations API are safe here by construction. Both are driven by the document timeline, which advances with real time since the document loaded, so their progress is a function of the clock rather than of how many frames were drawn. WAAPI also gives you animation.finished, a promise that settles when the animation genuinely completes, which is the cleanest cleanup hook available.
Maintained JS libraries generally do the right thing too: GSAP's ticker passes elapsed milliseconds to every listener and additionally smooths large lag spikes rather than jumping the timeline forward. Check the ticker documentation of whatever you adopt instead of assuming, and never assume for code you inherited.
Choosing an animation library
Adopt a library that extends what the platform can do. Path morphing, shared-element layout animations, gesture-driven springs with velocity handoff, and orchestrated sequences across many elements are all genuinely hard to hand-roll, and a library that gives you them earns its bundle.
Avoid a library whose main contribution is wrapping CSS transitions in JavaScript. It costs bytes, it moves scheduling onto the main thread, and it buys nothing you could not write in six lines of CSS. If the API surface you actually use maps one-to-one onto transition and @keyframes, delete the dependency.
Checklist
- Images and videos have explicit dimensions or
aspect-ratioso nothing shifts on load - Repeated videos use generated aspect-ratio and placeholder metadata rather than per-call-site guesses
- Skeletons match the size of the content they replace; fonts for the first screen are preloaded
- Every web font ships as
.woff2, never.ttfor.otf -
will-changenames onlytransform,opacityorfilter, and only where measured or where it fixes a 1px to 2px jump - Changing numbers use
tabular-nums; no font-weight change on hover - Animations prefer
transformandopacity; notransition: all; blur stays under 20px - Where a layout property is animated on purpose, the reason is written down and the cost is measured, not assumed
- Important motion has been checked against its frame budget under realistic load, on a representative device
- Profiles are read by idle proportion, with the frames track and the red over-budget flags, not by eyeballing frame rate
- Recordings have been checked for long tasks, forced layout, paint cost, and unnecessary framework renders
- Suspect code is diffed baseline against treatment, second recording in a fresh tab
- A climbing node count is checked against a collection event before being called a leak
- The animation has been run on a low-end laptop, a budget Android phone, and an older iPhone
- Actions feel instant via optimistic UI, press feedback, and skeletons over spinners
- Lists past a few hundred rows are virtualized
- Routes and data prefetch on hover or focus; off-screen work is paused
- Predetermined motion uses CSS (transitions over keyframes); dynamic motion uses JS/springs, with per-frame work kept outside framework render cycles
- Programmatic CSS-grade animation uses WAAPI rather than a main-thread loop, and uses it wherever pause, reverse, cancel, or runtime retargeting is needed
- JS-library animations use the full
transformstring rather than the main-threadx/y/scaleshorthands, and the result is measured rather than assumed composited - Cleanup is keyed to
animationendoranimation.finished, never to a timer sized from the duration - Every animation dependency earns its bytes by doing something CSS cannot