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.
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
getBoundingClientRectbookkeeping. - 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
transformoropacityon 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
- Open Performance panel β enable Screenshots and Layers β record a transition.
- Filter for
Layoutevents during the animation window. The View Transition API should register0 msmain-thread layout while pseudo-elements are animating. - Open the Memory tab and watch for linear growth across rapid transitions. Each unreleased
::view-transition-oldlayer is a bitmap leak. Confirmtransition.finishedis awaited before the nextstartViewTransitioncall.
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
- Record a Performance trace during the animation.
- Filter for
Recalculate Styleevents. 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. - Confirm that
getBoundingClientRectis not called inside therequestAnimationFramecallback β 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.
Related
- How @view-transition Works Under the Hood β parent: snapshot lifecycle and pseudo-element hierarchy
- SPA page swap animations β applying view transitions to route changes
- Cross-route element morphing β shared-element animation between pages
- Fallback strategies for legacy browsers β broader
@supportsand polyfill patterns - How to respect prefers-reduced-motion in CSS β accessibility requirements that apply to both techniques