Scaling Small Layers Instead of Animating Large Ones
Part of GPU Memory & Texture Management in Performance Budgeting & GPU Architecture.
The problem
A menu opens with a coloured panel that expands from the button to fill the screen. A page transition slides a full-viewport curtain across. A material-style ripple spreads out from a tap until it covers a large card. Each effect is a compositor-only transform animation and each still stutters on its first frames on a mid-range phone.
The stutter is not the animation — it is the texture. A full-viewport layer on a 412 by 915 CSS pixel screen at device pixel ratio 2.625 is roughly 1080 by 2400 device pixels, over 10MB of GPU memory, which has to be rasterised and uploaded before the first frame can be shown.
Root cause analysis: texture cost is area, not visual complexity
A composited layer is backed by a texture sized to the element’s area multiplied by the square of the device pixel ratio, as described in GPU memory and texture management. A solid purple rectangle and a detailed photograph of the same size cost the same memory and similar upload time. The GPU does not know that the purple rectangle could have been described in four bytes.
That is the opening this technique uses. A solid colour looks identical at any scale: stretching a 4 by 4 pixel purple texture to cover the screen produces a perfectly sharp purple screen. So an element that is drawn small and scaled large costs a tiny texture and looks the same as a full-size one.
Content with detail — text, images, borders, gradients with visible banding — is a different case. Stretching a small texture blurs it. For those, the trick runs the other way: lay the element out and rasterise it at its large final size, then start the animation from an inverse scale so it appears small. The texture is large but the result is sharp throughout, and it is rasterised once rather than repeatedly as it grows.
Step-by-step resolution
The cover scale is the distance from the anchor point to the farthest viewport corner, divided by the backdrop’s radius. It only needs computing when the menu opens, not per frame.
Production code pattern
<nav class="menu" data-open="false">
<span class="menu__backdrop" aria-hidden="true"></span>
<div class="menu__content"> … links … </div>
</nav>
.menu__backdrop {
position: fixed;
top: var(--anchor-y);
left: var(--anchor-x);
inline-size: 16px; /* drawn tiny */
block-size: 16px;
margin: -8px 0 0 -8px; /* centre on the anchor point */
border-radius: 50%;
background: var(--color-primary-700);
scale: 0;
transition: scale 420ms cubic-bezier(0.4, 0, 0.2, 1);
}
.menu__content {
position: fixed;
inset: 0;
opacity: 0;
scale: 0.98; /* close to 1: laid out and rasterised at full size */
transition: opacity 200ms linear 320ms, scale 260ms ease-out 320ms; /* starts once the colour nearly covers */
}
.menu[data-open="true"] .menu__backdrop { scale: var(--cover-scale); }
.menu[data-open="true"] .menu__content { opacity: 1; scale: 1; }
.menu[data-animating] :is(.menu__backdrop, .menu__content) {
will-change: scale, opacity; /* promoted only during the transition */
}
@media (prefers-reduced-motion: reduce) {
.menu__backdrop { transition: opacity 150ms linear; scale: 200; opacity: 0; }
.menu[data-open="true"] .menu__backdrop { opacity: 1; }
.menu__content { scale: 1; transition: opacity 150ms linear; }
}
function openMenu(button) {
const r = button.getBoundingClientRect(); // one read, before any writes
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const far = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
menu.style.setProperty('--anchor-x', `${x}px`);
menu.style.setProperty('--anchor-y', `${y}px`);
menu.style.setProperty('--cover-scale', String(Math.ceil(far / 8))); // radius is 8px
menu.toggleAttribute('data-animating', true);
menu.dataset.open = 'true';
menu.querySelector('.menu__content').addEventListener('transitionend', () => {
menu.removeAttribute('data-animating');
}, { once: true });
}
Rendering Impact: composite. The backdrop’s texture is a few hundred device pixels regardless of how far it scales; the content layer is full-size but is uploaded once, after the colour already covers the screen, so its upload never competes with the first frames of motion.
A circle scaled 60 times has soft, anti-aliased edges that are also stretched, so the expanding edge is slightly blurry. At the speeds involved nobody sees it, and once the circle covers the viewport its edge is off-screen. If the edge must stay crisp during a slow reveal, clip-path: circle() gives a sharp edge at a paint cost.
Verification checklist
Constraints and trade-offs
- Only visually uniform content scales invisibly; subtle gradients may band when stretched far.
- Very large scale factors can hit implementation limits on some GPUs; divide the job between a larger starting element and a smaller factor if edges shimmer.
- Splitting one panel into backdrop and content adds markup and a coordination step between two animations.
- Scaling down into place still allocates the final-size texture; the saving is in re-rasterisation and timing, not memory.
- Browsers may re-rasterise a scaled layer at the end of the animation, costing one paint of the final size.
Frequently asked questions
Why not just animate a full-size element with transform?
You can, but the full-size texture has to be rasterised and uploaded before the first frame, which is where the stutter comes from on phones. A small element scaled up gets the same visual result with a texture measured in kilobytes.
Will a scaled-up element look blurry?
Only if it has detail. A solid colour looks identical at any scale. Text, images and borders blur when stretched, so draw those at final size and animate from a smaller scale instead.
Does this help on desktop?
Less, because desktop GPUs have more memory and faster uploads, and device pixel ratios are often lower. The technique matters most on high-density phones where full-viewport textures are largest.
How do I pick the starting size of the backdrop?
Small enough that its texture is negligible, large enough that the scale factor stays reasonable. Something between 8 and 32 CSS pixels works well for full-screen covers.
Related
- GPU Memory & Texture Management — how texture size is calculated
- Texture Memory Budget for Mobile — how much there is to spend
- Fixing Blurry Text After Transform Animations — what happens when detailed content is stretched