Home / Articles / Animation & Motion /
Size transitions and layout shifts
Animating width and height triggers expensive layout recalculations. Learn the transform trick and container-ready techniques for smooth size changes.
The problem: size transitions that jank
You want to animate a card expanding from 100px to 200px tall. The obvious approach is to transition the height property. But on anything but the fastest devices, the animation stutters. Worse: the entire page seems to jerk as other elements reflow to accommodate the growing card.
This is the fundamental cost of size transitions. When you animate width, height, margin, or any property that affects element dimensions, the browser must recalculate layout for every frame. This means the positions and sizes of potentially every element on the page must be recomputed — an operation that grows exponentially with page complexity.
The visual symptoms are unmistakable on lower-end mobile devices or when many elements share the same layout context. A card that should expand smoothly instead appears to snap forward in noticeable increments. Other elements briefly shift, then shift again as the browser catches up to the intermediate animation states. This is layout thrashing in action: the expensive recalculation cycle that breaks the animation's 60fps rhythm.
Understanding layout thrashing
Layout thrashing occurs when animation forces the browser to recalculate layout repeatedly. Each frame of a width or height animation triggers this expensive operation. The browser can't skip ahead — it must compute the exact size at each intermediate point because that affects everything downstream.
/* This causes layout recalc on every frame */
.card {
height: 100px;
transition: height 0.4s ease;
}
.card.expanded {
height: 200px;
}
/* Performance: Layout → Paint → Layout → Paint → ... (per frame) */
On a page with just a few elements, this might be tolerable. But multiply that by dozens of cards, or add in complex descendants, and the frame budget is consumed before the animation even begins. The cost compounds: if a single layout pass takes 10ms on a particular device, a 16.6ms frame budget allows only one layout per frame. Any animation triggering layout will miss frames consistently.
This is why animating dimensions feels cheap on a development machine with an M4 chip but feels broken on a mid-range Android phone. Modern desktops can sometimes absorb the hit, but the web's audience includes billions of devices where every millisecond counts. Your animation must work across this entire spectrum.
The solution: transform scale
The key insight is that transform doesn't affect layout — it's a purely compositional operation. You can use scale() to simulate size changes without triggering layout recalculation.
/* Smooth animation using transform */
.card {
transform-origin: top center;
transform: scaleY(1);
transition: transform 0.4s ease;
/* Modern color for subtle depth */
background: oklch(0.25 0.02 240 / 0.95);
border: 1px solid oklch(0.55 0.12 250 / 0.5);
}
.card.expanded {
transform: scaleY(2);
}
/* Performance: Compositing only, no layout recalcs */
This approach has a caveat: it affects the element's rendering area without changing its layout box. Neighboring elements still think the card is 100px tall, so visual overlap can occur. This is acceptable for certain patterns — modals, dropdowns, overlays — but problematic for document flow layouts.
Understanding why transform is so efficient reveals the browser's rendering pipeline. Operations in the compositing stage happen on the GPU as a separate pass after layout and paint are complete. By staying in this final stage, animations bypass the expensive CPU-bound layout and paint phases entirely. This is the foundation of modern CSS performance optimization.
The grid-fit technique
When you need size animation without overlap, combine transform with a wrapper. Set the explicit height on an inner element, but use transform on the outer container for the visual size change.
.card-outer {
transform-origin: top center;
transform: scaleY(1);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
/* Subtle border with oklch */
border: 1px solid oklch(0.55 0.12 250 / 0.5);
}
.card-inner {
height: 100px;
/* Use oklch for consistent vibrant color */
background: oklch(0.32 0.08 260);
border-radius: 0.5rem;
}
.card-outer.is-expanded {
transform: scaleY(2);
}
/* Inner content size changes visually without layout thrashing */
This technique is particularly useful for accordion patterns. Each accordion item has its own wrapper, so only that item's height needs to be recalculated by the parent grid or flex container when it expands or collapses.
The transform-based wrapper works especially well when paired with a scroll-snap parent layout. Each accordion item maintains its place in the document flow while the transform provides the animation. The parent only needs to update its own layout when the transform scale changes, which is far more efficient than recalculating every sibling element's position for each frame.
Container queries for responsive size
With container queries, you can change sizes based on the container rather than viewport. This is more efficient than media queries because changes are localized to the container's descendants rather than triggering document-wide reflows.
.card {
container-type: inline-size;
height: 150px;
transition: height 0.3s ease;
/* oklch hue shift in expanded state */
background: oklch(0.28 0.06 255);
}
@container (min-width: 400px) {
.card {
height: 200px; /* Only recalculates for this container */
background: oklch(0.35 0.1 245); /* Subtle color shift */
}
}
While this still triggers layout, container query recalculation is more surgical than media query recalculation because it's scoped. For large documents, this can meaningfully reduce jank.
Container queries work beautifully with the transform approach. You can define container-specific scale values that account for different content densities. A narrow container might use a larger scale factor to reveal more content, while a wide container uses a gentler scale change. This localization means your layout changes adapt gracefully across different embedding contexts without global side effects.
animate-size property
The upcoming CSS animate-size property (part of Web Animations improvements) promises to make size transitions more performant. When applied, the browser uses a more efficient size interpolation method that avoids full layout recalculation on each frame.
/* Future: smoother size transitions */
.card {
width: 100px;
animate-size: smooth;
transition: width 0.4s;
}
.card.wide {
width: 200px;
}
As of 2026, this property has limited support. Keep an eye on browser updates — when widely available, it will remove much of the complexity from this article.
Transitioning max-width/max-height
Transitioning max-width and max-height from a constrained value to none is a common pattern for show/hide animations. This triggers layout but is more limited in scope than animating explicit width or height.
.details-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
background: oklch(0.22 0.04 265 / 0.8);
}
.details-content[open] {
max-height: 1000px; /* Arbitrary large value */
}
/* Still causes layout, but contained scope */
The max-height workaround (setting it to an arbitrarily large value like 1000px) has a limitation: transition time depends on the chosen value. A shorter max-height makes for faster animation, a larger value makes it slower. This is a tradeoff for CSS-only accordions.
A refined approach uses CSS custom properties to parameterize the max-height value. By setting --max-open-height on the element via JavaScript after measuring content, you can calculate a precise transition duration that matches the actual content height. This removes the artificial timing variance while preserving the CSS-only trigger mechanism.
content-visibility: auto
The content-visibility: auto property can speed up layout recalculation by telling the browser to skip rendering off-screen content. For lists with expandable items, this significantly reduces jank when expanding items below the fold.
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 200px;
/* oklch background with opacity */
background: oklch(0.3 0.05 250 / 0.9);
}
/* Browser can skip layout work for off-screen items
when one item expands */
This doesn't eliminate layout recalculation, but it reduces the scope of what must be recalculated. For long scrollable lists, this makes a noticeable difference.
Combine content-visibility: auto with grid or flex containers that have overflow: contain. The overflow containment restricts layout recalculation to the scrolling container, while content-visibility allows the browser to skip items outside the viewport. This combination is particularly effective for expandable detail lists, infinite feeds, and navigation menus with submenus.
Practical checklist
When implementing size transitions, follow these guidelines:
- Prefer
transform: scale()for visual size changes where overlap is acceptable. - Use
max-heighttransitions for content reveal/hide patterns despite the timing limitation. - Apply
content-visibility: autoto containers with expandable children to reduce layout scope. - Set
transform-origincarefully to get the desired expansion direction. - Avoid animating
width/heightdirectly except on very simple documents. - Use container queries to localize size changes to scoped regions.
The expand-measure-collapse technique
For accordion interfaces where precise height animation is required, the expand-measure-collapse pattern uses JavaScript to measure content height before animation. The element expands instantly (skipping animation), JavaScript measures the content height, then the element collapses with the measured height as the end value.
// Measure content after initial expansion
function animateAccordionHeight(element) {
element.style.height = 'auto';
const measured = element.scrollHeight;
element.style.height = '0px';
// Force reflow
element.offsetHeight;
// Animate to measured height
element.style.transition = 'height 0.3s ease-out';
element.style.height = measured + 'px';
}
While this requires JavaScript, the animation quality is superior to CSS-only alternatives because it uses the exact content height rather than an arbitrary max-height. The transition runs smoothly because the measured height is known, avoiding the timing variance inherent in the max-height workaround.
CSS grid auto-rows animation
Modern CSS Grid supports animating grid-auto-rows with reasonable performance characteristics. When a grid item expands, the grid-auto-rows value can transition between explicit sizes, affecting only items within that grid rather than the entire document.
.grid-container {
display: grid;
grid-auto-rows: 100px;
transition: grid-auto-rows 0.4s ease-out;
gap: 1rem;
}
.grid-container.expanded {
grid-auto-rows: 150px;
}
/* Styling with oklch colors */
.grid-item {
background: oklch(0.35 0.08 255);
border: 1px solid oklch(0.6 0.15 260 / 0.4);
border-radius: 0.5rem;
}
This approach works best when all grid items share the same expansion behavior. The grid container itself handles the size change, and each item's expansion is calculated relative to the auto-rows value. Browser support is solid in modern engines, though very old browsers will fall back to instant changes without animation.