60fps-animation
Back to list

iart-ai/60fps-animation

60fps Animation

GitHub

Web animation performance guidance for avoiding layout thrashing and reaching smooth 60/120fps motion with compositor-friendly techniques.

2K installsMotionPerformanceFrontend0 demos

Real-world examples not captured yet

This skill shows a gallery of live HTML demos (CDN libraries welcome — GSAP, Three.js, etc.) rendered inline via srcdoc.

Skill markdown
# Performant Web Animation

Eliminate the #1 cause of janky web animation: animating properties that force the browser to recalculate layout (reflow) or repaint on every frame. The browser renders in stages — **layout → paint → composite**. Animating `width`, `height`, `top`, `left`, `margin`, `padding`, `box-shadow`, or `filter` re-runs layout and/or paint each frame, blocking the main thread. Animating **`transform`** and **`opacity`** runs entirely on the compositor (often the GPU), skipping layout and paint, which is what makes animation smooth at 60/120fps.

## When to use

Use when an animation stutters or drops frames, when a hover/scroll effect feels heavy, when animating size/position/shadow, when a layout change needs to animate smoothly (cards reordering, an element moving between containers), when animating `height: auto`, or when reviewing animation code for performance.

## Core rule: animate only transform and opacity

Map every "expensive" animation to a cheap equivalent.

| Animating (expensive) | Triggers | Replace with (cheap) |
|---|---|---|
| `width` / `height` | Layout + Paint | `transform: scaleX()/scaleY()` (+ FLIP for true size) |
| `top` / `left` / `margin` | Layout + Paint | `transform: translate()` |
| `box-shadow` | Paint | Animate `opacity` of a pseudo-element holding the shadow |
| `filter: blur()` | Paint (heavy) | Cross-fade two layers via `opacity`, or accept sparingly |
| `background-position` | Paint | `transform: translate()` on a child layer |
| `color` / `background` | Paint | Often acceptable; or cross-fade layers |

### Position and size via transform

```css
/* BAD: animates layout every frame */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }

/* GOOD: compositor-only */
.box {
  transition: transform 300ms ease;
  transform: translateX(0) scaleX(1);
  transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
```

`scaleX` distorts inner content (text stretches). For true resizing without distortion, use **FLIP**.

## Cheap box-shadow via pseudo-element opacity

Animating `box-shadow` repaints a large blurred region every frame. Instead paint the shadow once on a `::after`, then animate only its `opacity`.

```css
.card { position: relative; }
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 12px 28px rgba(0,0,0,0.35);
  opacity: 0;
  transition: opacity 300ms ease;
  pointer-events: none;
}
.card:hover::after { opacity: 1; }
```

The blurred shadow is rasterized once; hovering only changes a compositor opacity — smooth at any frame rate.

## FLIP: animate layout changes cheaply

FLIP (First, Last, Invert, Play) animates a *layout* change (reorder, resize, move between containers) using only `transform`. Measure where the element was (First) and will be (Last), apply an inverting transform so it visually appears unmoved, then animate the transform back to identity (Play). The DOM ends in its real final layout; the motion is pure compositor work.

```js
function flip(el, mutate) {
  const first = el.getBoundingClientRect();   // First
  mutate();                                    // change the DOM/layout
  const last = el.getBoundingClientRect();     // Last

  const dx = first.left - last.left;
  const dy = first.top - last.top;
  const sx = first.width  / last.width;
  const sy = first.height / last.height;

  el.animate(
    [
      { transformOrigin: 'top left',
        transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
      { transformOrigin: 'top left', transform: 'none' },               // Play
    ],
    { duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
  );
}

// usage: animate an element moving to a new grid position
flip(card, () => targetContainer.appendChild(card));
```

The Web Animations API `animate()` runs the transform on the compositor. For many elements, batch all `getBoundingClientRect()` reads *before* any mutation (see layout thrashing below).

## Animating height: auto

`height: auto` is not interpolatable historically. Modern and fallback approaches:

```css
/* Modern (Chrome 129+/supporting browsers): opt size keywords into animation */
.panel {
  interpolate-size: allow-keywords;   /* enables animating to/from auto */
  height: 0;
  overflow: clip;
  transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() also works: height: calc-size(auto, size); */
```

```css
/* Robust fallback today: CSS grid 1fr -> 0fr */
.wrapper {
  display: grid;
  grid-template-rows: 0fr;            /* collapsed */
  transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }
```

The grid trick animates the track size (compositor-friendly enough and broadly supported) and needs no JS height measurement. Use `interpolate-size: allow-keywords` / `calc-size(auto, size)` where the tar

…

More from iart-ai