# Minimize Paint Animations — Except on Small, Local UI

**SHOULD** · **ID:** `animations-ibelick-minimize-paint` · **Category:** animations
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-ibelick-minimize-paint

> SHOULD: Avoid animating paint properties (background, color, box-shadow) except for small, local UI elements. Large paint areas cause jank on lower-end devices.

Avoid animating paint properties on large surfaces — but a color transition on a button label or an icon is fine

The exception is the useful half of this rule, and it is easy to lose. A paint animation forces the browser to re-fill pixels on the main thread every frame, and the cost scales with the AREA being repainted — so it is the same size budget as the surface-area rule, not a property blacklist. Repainting a full-bleed hero background or eight pulsing cards is expensive enough to visibly stall; repainting the 60x20px of a button label on hover is not, and refusing to do it buys you nothing but a worse interface. So: `transition-colors` on a button, a link, an icon — fine, and the correct thing to reach for. The same transition on a large container, or running continuously rather than once, is what the rule is aimed at. Two extensions worth knowing that upstream does not state: `box-shadow` behaves the same way (it is a paint property, and shadows are expensive to rasterize — see the example), and when you do need an apparently paint-like change on a large surface, pre-paint it as a second layer and crossfade the two with `opacity`, which is composited.

## Bad — do not do this

`animations-ibelick-minimize-paint-bad`

```tsx
import { blockMainThread } from '@/lib/demo';

const cards = Array.from({ length: 8 }, (_, i) => i);

export function IbelickMinimizePaintBad() {
  return (
    <div className="space-y-4">
      <p className="text-sm text-muted-foreground">Click "Run heavy task" and watch the cards.</p>
      <div className="grid grid-cols-4 gap-2">
        {cards.map((i) => (
          <div
            key={i}
            className="h-12 rounded-md bg-primary"
            style={{ animation: 'paintPulse 1.2s ease-in-out infinite alternate' }}
          />
        ))}
      </div>
      <button
        onClick={() => blockMainThread(800)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Run heavy task (0.8s)
      </button>
      <style>{`
        @keyframes paintPulse {
          from { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
          to { box-shadow: 0 8px 24px 4px rgba(0,0,0,0.5); }
        }
      `}</style>
      <p className="text-xs text-destructive">
        Animating <code>box-shadow</code> repaints every frame on the main thread — the cards freeze while it's busy
      </p>
    </div>
  );
}
```

## Good — do this

`animations-ibelick-minimize-paint-good`

```tsx
import { blockMainThread } from '@/lib/demo';

const cards = Array.from({ length: 8 }, (_, i) => i);

export function IbelickMinimizePaintGood() {
  return (
    <div className="space-y-4">
      <p className="text-sm text-muted-foreground">Click "Run heavy task" and watch the cards.</p>
      <div className="grid grid-cols-4 gap-2">
        {cards.map((i) => (
          <div key={i} className="relative h-12 rounded-md bg-primary">
            {/* Pre-painted shadow, crossfaded with opacity (compositor). */}
            <div
              className="absolute inset-0 rounded-md shadow-[0_8px_24px_4px_rgba(0,0,0,0.5)]"
              style={{ animation: 'shadowFade 1.2s ease-in-out infinite alternate' }}
            />
          </div>
        ))}
      </div>
      <button
        onClick={() => blockMainThread(800)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Run heavy task (0.8s)
      </button>
      <style>{`
        @keyframes shadowFade {
          from { opacity: 0; }
          to { opacity: 1; }
        }
      `}</style>
      <p className="text-xs text-success">
        Crossfade a pre-painted shadow layer with <code>opacity</code> — composited, so the cards keep pulsing even while the main thread is busy
      </p>
      <p className="text-xs text-muted-foreground">
        Note the button itself still animates a paint property (<code>transition-colors</code> on hover). That is the carve-out, not a violation: it repaints one small, local control once per hover, not eight large surfaces continuously.
      </p>
    </div>
  );
}
```

## References

- [ibelick — baseline-ui SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/baseline-ui/SKILL.md)
- [ibelick — fixing-motion-performance SKILL.md](https://github.com/ibelick/ui-skills/blob/main/skills/fixing-motion-performance/SKILL.md)
- [Simplify Paint Complexity](https://web.dev/articles/simplify-paint-complexity-and-reduce-paint-areas)
