View Transition API vs FLIP Technique: When to Use Each

Both the View Transition API and the FLIP technique animate elements between two DOM states, but they diverge sharply in where the work happens: FLIP runs geometry reads and writes on the main thread, while the View Transition API hands interpolation off to the compositor thread after capturing GPU bitmaps. That architectural split β€” explored in depth in How @view-transition Works Under the Hood β€” is the primary deciding factor for which approach to reach for.

Thread model diagram

The diagram below shows the execution timeline for each technique across a single 400 ms animation. Labels at the right show where jank risk accumulates.

Thread model: View Transition API vs FLIP Two rows show the main thread and compositor thread activity for each technique over 400ms. The View Transition API confines main-thread work to the snapshot phase, then runs on the compositor. FLIP runs getBoundingClientRect reads, DOM writes, and forced layout on the main thread throughout. 0 100ms 200ms 300ms View Trans. Main thread Compos- itor FLIP Main thread snap compositor interpolation (main thread free) no jank read write read layout rAF transition (main thread involved) reflow main-thread work DOM write forced layout compositor

When to use this approach

Deciding between the two techniques comes down to browser support requirements, geometry complexity, and how much control you need over intermediate frames.

Prefer the View Transition API when:

  • Your target audience is on Chrome 111+ or Safari 18+, and a no-animation fallback for older browsers is acceptable.
  • You need shared-element morphing across route changes without manual getBoundingClientRect bookkeeping.
  • The animation involves many elements (e.g. a list of cards) where tracking every element’s old and new rectangle would be error-prone.
  • Jank-free 60 fps on mid-range devices is a hard requirement β€” compositor-thread interpolation is the only reliable path to it.

Prefer FLIP when:

  • You need to support browsers without View Transition API support and a bare crossfade fallback is not sufficient (e.g. the motion is load-bearing UI feedback).
  • The animation requires access to real intermediate frame states β€” for example, a physics-based spring easing that reads measured velocity between frames.
  • You are animating a small number of well-known elements where manual geometry tracking is manageable and the overhead of bitmap allocation is not justified.
  • You need to animate properties that are not transform or opacity on the compositor β€” FLIP is compatible with anything you can express as a CSS transition.

Implementation

Step 1 β€” FLIP: batch reads before writes

The critical discipline in FLIP is preventing layout thrashing. A call to getBoundingClientRect after a DOM write forces a synchronous reflow. Batch all reads before all writes:

// 1. Read before any mutations
const firstRect = element.getBoundingClientRect();

// 2. Mutate the DOM
updateDOM();

// 3. Read again β€” safe because no write happened since step 1
const lastRect = element.getBoundingClientRect();

// 4. Calculate the inverse transform
const dx = firstRect.left - lastRect.left;
const dy = firstRect.top  - lastRect.top;
const dw = firstRect.width  / lastRect.width;
const dh = firstRect.height / lastRect.height;

// 5. Apply inverse (no animation yet)
element.style.transition = 'none';
element.style.transform = `translate(${dx}px, ${dy}px) scale(${dw}, ${dh})`;
element.style.transformOrigin = '0 0';

// 6. Force the browser to commit the inverse transform
element.offsetHeight; // triggers layout flush

// 7. Play forward to the natural position
element.style.transition = 'transform 0.4s cubic-bezier(0.2, 0, 0, 1)';
element.style.transform = '';

The element.offsetHeight read in step 6 looks like a violation of the read-then-write rule, but here it is intentional: you need the inverse transform painted before the animation starts, so the single forced reflow is load-bearing.

Step 2 β€” View Transition API: minimal wiring

const transition = document.startViewTransition(() => updateRoute());

// transition.ready  β€” pseudo-elements created, animations about to start
// transition.finished β€” all animations complete
await transition.finished;

Name each element you want to morph with view-transition-name. The browser diffs positions automatically:

.product-card {
  view-transition-name: product-card;
  contain: layout;
}

::view-transition-old(product-card) {
  animation: card-exit 0.35s cubic-bezier(0.2, 0, 0, 1) forwards;
}
::view-transition-new(product-card) {
  animation: card-enter 0.35s cubic-bezier(0.2, 0, 0, 1) forwards;
}

@keyframes card-exit {
  to { opacity: 0; transform: scale(0.95); }
}
@keyframes card-enter {
  from { opacity: 0; transform: scale(1.05); }
}

Step 3 β€” Scroll-driven integration

When a scroll threshold triggers a view transition, bind animation-timeline properties only after transition.ready resolves. Attaching them before the snapshot phase completes causes the browser to promote compositor layers before the bitmap is stable, producing a visible one-frame flash:

const transition = document.startViewTransition(updateRoute);

transition.ready.then(() => {
  const el = document.querySelector('.scroll-element');
  el.style.animationTimeline = 'scroll(root)';
  el.style.animationRange = 'entry 0% cover 50%';
});

For more on connecting scroll timelines to transition moments, see SPA page swap animations with scroll-driven integration.

Step 4 β€” Progressive enhancement wrapper

Neither technique should run unconditionally. Wrap both in a progressive enhancement guard:

function navigateWithTransition(updateFn) {
  if (!matchMedia('(prefers-reduced-motion: reduce)').matches === false) {
    // Reduced motion: update instantly, no animation
    updateFn();
    return;
  }

  if (document.startViewTransition) {
    // Modern path: compositor-thread morphing
    document.startViewTransition(updateFn);
  } else {
    // Legacy fallback: FLIP with transform + opacity only
    document.documentElement.classList.add('is-transitioning');
    updateFn();
    requestAnimationFrame(() => {
      document.documentElement.classList.remove('is-transitioning');
    });
  }
}

The (prefers-reduced-motion: reduce) check applies to both paths. Never treat prefers-reduced-motion as a concern only for the modern API β€” FLIP animations are equally subject to vestibular accessibility requirements.

Verification

DevTools check β€” View Transition API

  1. Open Performance panel β†’ enable Screenshots and Layers β†’ record a transition.
  2. Filter for Layout events during the animation window. The View Transition API should register 0 ms main-thread layout while pseudo-elements are animating.
  3. Open the Memory tab and watch for linear growth across rapid transitions. Each unreleased ::view-transition-old layer is a bitmap leak. Confirm transition.finished is awaited before the next startViewTransition call.

For deeper compositor tracing, open chrome://tracing, enable devtools.timeline,blink.graphics_context, then search for SurfaceLayer and rasterization events β€” each view-transition-name should appear as its own composited layer.

DevTools check β€” FLIP

  1. Record a Performance trace during the animation.
  2. Filter for Recalculate Style events. Correct FLIP should show a single spike at the start (from the forced layout flush); additional spikes mid-animation indicate a read-write interleave bug.
  3. Confirm that getBoundingClientRect is not called inside the requestAnimationFrame callback β€” that read happens on the wrong side of the write and will force a reflow every frame.

Automated assertion

// Verify no layout thrashing in FLIP implementation
const observer = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'layout-shift' && entry.value > 0.01) {
      console.warn('Unexpected layout shift during FLIP:', entry.value);
    }
  }
});
observer.observe({ type: 'layout-shift', buffered: true });

Edge cases and gotchas

Rapid navigation β€” new transition fires before the old one finishes. Call activeTransition.skipTransition() before calling startViewTransition again. Without this, the browser queues the second transition and both old and new bitmaps may reference stale DOM:

let activeTransition = null;

function navigate() {
  if (activeTransition) activeTransition.skipTransition();
  activeTransition = document.startViewTransition(() => updateDOM());
  activeTransition.finished.finally(() => { activeTransition = null; });
}

Duplicate view-transition-name values. Two elements sharing a name at capture time cause the browser to silently discard the per-element animation and fall back to a full-page crossfade for those elements. Use unique names scoped to the data id: view-transition-name: card-${item.id}.

FLIP scale distortion on text. When dw !== dh, scaling a text element with a non-uniform scale() transform compresses or stretches the glyphs visually before they snap back. Either animate width/height directly on a wrapper with will-change: transform (accepting the main-thread reflow) or constrain FLIP to uniform-scale or translate-only animations.

Scroll-timeline conflict mid-transition. If the element already has animation-timeline: scroll() applied before startViewTransition is called, the browser may rasterize the bitmap at a scroll-position-dependent transform, producing a snapshot of the element mid-animation rather than at its final painted state. Disable the scroll timeline in the callback, re-enable on transition.finished:

document.startViewTransition(() => {
  el.style.animationTimeline = 'none';
  updateDOM();
}).finished.then(() => {
  el.style.animationTimeline = 'scroll(root)';
});

contain: layout requirement for view-transition-name. Elements with view-transition-name need contain: layout (or contain: strict) to allow the browser to snapshot them independently. Without it, the browser falls back to snapshotting the entire viewport for that name, inflating GPU memory use.

Browser-specific notes

Chrome (111+, single-document; 126+ cross-document). The reference implementation. Single-document transitions are stable; cross-document @view-transition in navigation requires opt-in in both pages’ CSS. Rapid transitions that skip before the compositor has composited all layers can occasionally produce a one-frame white flash β€” the skipTransition() guard above prevents this.

Safari (18+, single-document). Ships the same single-document API. Timing behaviour on transition.ready differs slightly: Safari may resolve transition.ready one microtask tick later than Chrome when the callback is async. Await transition.ready explicitly rather than relying on a synchronous .then() chain.

Firefox. As of mid-2026, the View Transition API is behind a flag (layout.view-transition.enabled) and not enabled by default for single-document transitions; cross-document support has not shipped. FLIP remains the only cross-browser path for Firefox users without the flag. Use if (document.startViewTransition) feature detection β€” never assume the API is available.

FLIP across all browsers. getBoundingClientRect is fully supported everywhere. The only browser-specific difference is the cost of forced reflow on complex DOMs: Chrome and Firefox handle the forced layout flush in step 6 in a single style recalculation; Safari can occasionally trigger two passes if the element’s ancestors have pending flex or grid layout. Keep the inverse-transform target’s containing block as simple as possible.