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.

Two directions of the same trickScale up what has no detail; scale down into place what does.Two directions of the same trickSolid backdrop: draw small, scale upElement is 16 by 16 CSS pxScaled to cover 900 px of viewportTexture ~1 KB, stays sharpTiny upload, no blurText panel: draw final, scale downElement laid out at full sizeStarts at scale(0.2), ends at 1Rasterised once at final sizeSharp, one upload
Scale up what has no detail; scale down into place what does.

Step-by-step resolution

Rebuilding a full-screen menu openingSplit the effect into the colour that grows and the content that appears.Rebuilding a full-screen menu opening1Split the panel into a solid backdrop element and a content element.Each part can use the right technique2Size the backdrop as a small circle or square anchored at the button.Its texture is a few kilobytes3Compute the scale needed for the backdrop to cover the viewport from that point.One number, set as a custom property4Animate the backdrop's scale from 1 to that value.The colour fills the screen with no large upload5Fade and slightly scale the content in at full size once the backdrop covers.Text is never stretched6Remove will-change from both when the menu is open.
Split the effect into the colour that grows and the content that appears.

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.

Texture memory for the menu's animated surfaces412 by 915 CSS px viewport at DPR 2.625; content layer counted only where it is promoted during the backdrop's growth.Texture memory for the menu's animated surfacesFull-viewport panel scaled from the button10.4 MBFull-viewport panel, clip-path reveal10.4 MB16 px backdrop scaled up0.01 MBContent layer, promoted after cover10.4 MB
412 by 915 CSS px viewport at DPR 2.625; content layer counted only where it is promoted during the backdrop's growth.

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.