DNAðŸŽĻ CSSAnimations, Transitions & Motion
ðŸĶ–DinosaurCSSAnimationsPerformance

Animations, Transitions & Motion

CSS animation performance isn't about writing fewer animations — it's about animating the right properties. The difference between 60fps and jank comes down to which rendering pipeline stages you trigger.

Animations, Transitions & Motion

Every jank frame is a broken promise to the user. Senior engineers don't just make things move — they understand which properties are cheap to animate, why some animations drop frames, and how to measure the difference.

The Rendering Pipeline

Understanding animation performance requires understanding what the browser does for each frame:

JavaScript → Style → Layout → Paint → Composite
                ↑         ↑        ↑          ↑
          recalc styles   reflow   repaint   GPU compositing
 
Cheapest animations only trigger: ──────────────────→ Composite
Mid-cost animations trigger:      ────────→ Paint → Composite
Expensive animations trigger:     → Layout → Paint → Composite

Property Animation Cost Tiers

TierPropertiesPipeline CostFPS Impact
Composite-onlytransform, opacityCompositing only60fps smooth
Paintcolor, background, box-shadow, border-radiusPaint + CompositeUsually fine, watch on low-end devices
Layoutwidth, height, margin, padding, top, left, font-sizeLayout + Paint + CompositeJanky, avoid for animations

The golden rule: Animate only transform and opacity. Everything else is a compromise.

CSS Transitions

Transitions animate between two states — triggered by a property change (hover, class toggle, etc.):

.button {
  background: var(--color-primary);
  transform: scale(1);
  transition: transform 200ms ease-out, background 150ms ease;
}
 
.button:hover {
  background: var(--color-primary-dark);
  transform: scale(1.05);
}

Timing Functions

.element {
  /* Keywords */
  transition-timing-function: ease;        /* Slow start and end */
  transition-timing-function: ease-in;     /* Slow start */
  transition-timing-function: ease-out;    /* Slow end (most natural) */
  transition-timing-function: ease-in-out; /* Slow start and end */
  transition-timing-function: linear;      /* Constant speed */
 
  /* Custom cubic-bezier for a snappy feel */
  transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
 
  /* Step functions for sprite animations */
  transition-timing-function: steps(6, jump-none);
}
Use CaseRecommended EasingWhy
Entering/appearingease-outDecelerates into resting position
Exiting/disappearingease-inAccelerates away naturally
State changeease-in-outSmooth transition between states
Micro-interactioncubic-bezier(0.34, 1.56, 0.64, 1)Slight overshoot feels responsive
Loading spinnerlinearConstant rotation

Keyframe Animations

For multi-step or looping animations:

@keyframes slide-in {
  from {
    transform: translateX(-100%);
    opacity: 0;
  }
  60% {
    opacity: 1;
  }
  to {
    transform: translateX(0);
  }
}
 
.panel {
  animation: slide-in 300ms ease-out forwards;
}
 
.spinner {
  animation: rotate 1s linear infinite;
}
 
@keyframes rotate {
  to { transform: rotate(360deg); }
}

animation-fill-mode: forwards is critical: without it, the element snaps back to its pre-animation state when the animation completes.

The FLIP Technique

FLIP (First, Last, Invert, Play) animates layout changes using only transform — turning an expensive layout animation into a cheap composite one:

function flipAnimate(element, changeFunction) {
  // First: record initial position
  const first = element.getBoundingClientRect();
 
  // Last: apply the DOM change (causes layout)
  changeFunction();
  const last = element.getBoundingClientRect();
 
  // Invert: calculate the delta and apply inverse transform
  const deltaX = first.left - last.left;
  const deltaY = first.top - last.top;
  const deltaW = first.width / last.width;
  const deltaH = first.height / last.height;
 
  element.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`;
  element.style.transformOrigin = 'top left';
 
  // Play: remove the transform with an animation
  requestAnimationFrame(() => {
    element.style.transition = 'transform 300ms ease-out';
    element.style.transform = '';
 
    element.addEventListener('transitionend', () => {
      element.style.transition = '';
      element.style.transformOrigin = '';
    }, { once: true });
  });
}

Why it works: The layout change happens instantly (one forced layout). The animation only uses transform, which is compositor-only. The user sees smooth movement even though the DOM position changed instantly.

will-change: Use and Misuse

will-change tells the browser to promote an element to its own compositor layer ahead of time:

/* Correct: applied just before animation */
.card:hover {
  will-change: transform;
}
 
.card:hover .overlay {
  transform: translateY(0);
}
/* WRONG: permanent will-change on many elements */
* {
  will-change: transform; /* Creates hundreds of compositor layers */
}
 
.every-card {
  will-change: transform, opacity; /* Wastes GPU memory */
}

Every will-change layer consumes GPU memory. A page with 200 cards each having will-change: transform can crash mobile browsers. Apply it dynamically, remove it after animation completes.

View Transitions API

The View Transitions API provides animated transitions between DOM states:

document.startViewTransition(() => {
  updateDOM();
});
/* Customize the transition animation */
::view-transition-old(root) {
  animation: fade-out 200ms ease-out;
}
 
::view-transition-new(root) {
  animation: fade-in 300ms ease-in;
}
 
/* Named transitions for specific elements */
.hero-image {
  view-transition-name: hero;
}
 
::view-transition-old(hero) {
  animation: scale-down 300ms ease-in-out;
}
 
::view-transition-new(hero) {
  animation: scale-up 300ms ease-in-out;
}

View Transitions work with MPA (multi-page apps) too, via the @view-transition at-rule:

@view-transition {
  navigation: auto;
}

CSS Scroll-Driven Animations

Scroll-driven animations bind animation progress to scroll position instead of time:

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(50px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
 
.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

Timeline Types

/* Scroll progress — tied to scroll container position */
.progress-bar {
  animation: grow-width linear;
  animation-timeline: scroll(root);
}
 
@keyframes grow-width {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}
 
/* View progress — tied to element entering/leaving viewport */
.section {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 40%;
}

These replace JavaScript scroll listeners with purely declarative, compositor-driven animations.

prefers-reduced-motion

Respecting user motion preferences is not optional:

/* Global reduced-motion override */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
 
/* Progressive enhancement approach: no motion by default */
.card {
  opacity: 1;
}
 
@media (prefers-reduced-motion: no-preference) {
  .card {
    animation: fade-in 300ms ease-out;
  }
}

The progressive approach is superior — animations are opt-in, not opt-out. Users who haven't set a preference get motion; users who have don't.

Web Animations API

WAAPI provides JavaScript control with CSS animation performance:

const animation = element.animate(
  [
    { transform: 'translateX(0)', opacity: 1 },
    { transform: 'translateX(100px)', opacity: 0 }
  ],
  {
    duration: 300,
    easing: 'ease-out',
    fill: 'forwards',
  }
);
 
animation.onfinish = () => element.remove();
animation.pause();
animation.reverse();
animation.playbackRate = 2;
 
const finished = await animation.finished;

WAAPI runs on the compositor thread (for transform/opacity), gives programmatic control, and returns Promises — unlike CSS animations which require animationend event listeners.

@starting-style for Entry Animations

@starting-style defines the initial state for elements entering the DOM (or transitioning to display: block):

.dialog {
  opacity: 1;
  transform: scale(1);
  transition: opacity 200ms, transform 200ms, display 200ms allow-discrete;
 
  @starting-style {
    opacity: 0;
    transform: scale(0.95);
  }
}
 
.dialog[hidden] {
  opacity: 0;
  transform: scale(0.95);
}

This enables CSS-only entry/exit animations without JavaScript or keyframes.

Interview Signal

Senior candidates demonstrate:

  1. Pipeline awareness — Explaining why transform/opacity are cheap (compositor-only) while width/top trigger full layout
  2. FLIP understanding — Describing the technique and why it converts layout animations to transform animations
  3. will-change discipline — Knowing the memory cost and applying it surgically, not globally
  4. Motion accessibility — prefers-reduced-motion as a first-class concern, progressive enhancement approach
  5. Modern API awareness — View Transitions, scroll-driven animations, WAAPI as replacements for JS-heavy animation libraries