# Budget Animation by Surface Size

**MUST** · **ID:** `animations-surface-size-budget` · **Category:** animations
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-surface-size-budget

> MUST: Budget by property TIMES area, not property alone: paint- or layout-triggering animation is acceptable only on small, isolated surfaces. Repainting a 32px icon is ~1,024px of raster work per frame; the same property on a full-bleed hero band is ~102,400px — roughly 100x — and it will miss frames. So the rule is not "never transition `filter`/`box-shadow`" — small and isolated, go ahead; large surface, move to `transform`/`opacity` on a promoted layer or do not animate it. Never blur a large surface. Duration is the second axis: one-shot effects are affordable far more often than continuous motion.

The cost of a paint or layout animation scales with the pixels it touches, so surface area decides what is affordable

This is the idea ibelick repeats more than any other, and it is the one that turns the property bans from dogma into engineering. The skill says it five ways: "do not animate layout continuously on large or meaningful surfaces", "do not animate paint-heavy properties on large containers", "paint-triggering animation is allowed only on small, isolated elements", "never animate blur on large surfaces", and the quote above. Read together they say something the flat bans do not: the property is not the whole cost — the property times the area is. Repainting a 32px icon is a few thousand pixels of raster work the GPU will not even notice; repainting a full-bleed hero is a million-plus pixels of raster work every frame, on the main thread, and it will miss frames. So the rule is not "never transition filter" — it is: on a small, isolated element, go ahead; on a large surface, move to transform and opacity on a promoted layer, or do not animate it at all. Duration is the second axis: "one-shot effects are acceptable more often than continuous motion".

## Bad — do not do this

`animations-surface-size-budget-bad`

```tsx
/**
 * BAD: a paint-heavy animation on a full-bleed surface.
 * Panning a background and pulsing a filter forces the browser to re-raster the
 * entire hero on every single frame — ~100,000 pixels of paint work, 60 times a second.
 */
export function SurfaceSizeBudgetBad() {
  return (
    <div className="space-y-3">
      <style>{`
        @keyframes ssb-pan { to { background-position: 240px 0; } }
        @keyframes ssb-pulse {
          0%, 100% { filter: blur(0px) brightness(1); }
          50%      { filter: blur(4px) brightness(1.15); }
        }
        .ssb-hero-bad {
          background-image: repeating-linear-gradient(45deg, currentColor 0 2px, transparent 2px 12px);
          animation: ssb-pan 3s linear infinite, ssb-pulse 3s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) { .ssb-hero-bad { animation: none; } }
      `}</style>

      <div className="ssb-hero-bad flex h-40 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
        <span className="rounded-md bg-card px-3 py-1 text-sm font-medium text-foreground">
          Full-bleed hero
        </span>
      </div>

      <p className="text-xs text-destructive">
        Both properties are paint-triggering and the surface is the whole viewport band: every frame
        re-rasters ~102,400px (640 x 160). Continuous, large, and paint-heavy — all three at once.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-surface-size-budget-good`

```tsx
/**
 * GOOD: the identical effect, budgeted by surface size.
 * The paint-heavy pan + filter stays on a 32px icon — small and isolated, which the
 * rule explicitly allows. The hero gets the same sense of life from transform and
 * opacity on a promoted layer, which the compositor handles with no paint at all.
 */
export function SurfaceSizeBudgetGood() {
  return (
    <div className="space-y-3">
      <style>{`
        @keyframes ssb-pan-small { to { background-position: 24px 0; } }
        @keyframes ssb-pulse-small {
          0%, 100% { filter: blur(0px) brightness(1); }
          50%      { filter: blur(2px) brightness(1.15); }
        }
        /* Same two paint-heavy properties — on 1,024px instead of 102,400px. */
        .ssb-icon {
          background-image: repeating-linear-gradient(45deg, currentColor 0 2px, transparent 2px 6px);
          animation: ssb-pan-small 3s linear infinite, ssb-pulse-small 3s ease-in-out infinite;
        }
        /* The hero moves on the compositor: no layout, no paint, just re-blending a layer. */
        @keyframes ssb-drift {
          0%, 100% { transform: translateX(0); opacity: 1; }
          50%      { transform: translateX(8px); opacity: 0.88; }
        }
        .ssb-hero-good { will-change: transform; animation: ssb-drift 3s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ssb-icon, .ssb-hero-good { animation: none; }
        }
      `}</style>

      <div className="flex h-40 items-center justify-center rounded-lg border border-border bg-muted">
        <div className="ssb-hero-good flex items-center gap-3 rounded-md bg-card px-3 py-2">
          <span className="ssb-icon size-8 rounded-sm text-muted-foreground" aria-hidden="true" />
          <span className="text-sm font-medium text-foreground">Full-bleed hero</span>
        </div>
      </div>

      <p className="text-xs text-success">
        Same properties, ~1/100th of the pixels: the icon is 32 x 32 = 1,024px of raster work per frame, the
        hero band was 102,400px. Size is the variable that decides — not the property name.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [web.dev — Animations guide](https://web.dev/articles/animations-guide)
