~/posts/view-transitions-and-the-overflow-escape

View transitions and the overflow escape

I’ve been seeing some examples of the View Transition API in the wild and it’s impressive. You can now animate navigations between pages natively, something that previously required JavaScript animation libraries or framework routing tricks.

It works like this:

  1. You tell the browser which elements are related by giving them matching view-transition-name.
  2. You then call startViewTransition() with a callback that swaps the DOM.
  3. The browser snapshots the old state, runs your callback, snapshots the new state, and animates between them.

Pretty clean API, but it has a blind spot around clipped layouts along with some accessibility issues that can trip up UIs if you’re not careful.

Setting up a view transition

Let’s put together a layout: a grid inside a clipped container, where clicking a row transitions to a detail view.

<div class="grid-panel" style="overflow: hidden; border-radius: 12px;">
<div class="row" style="view-transition-name: active-row">Dashboard</div>
<div class="row">Analytics</div>
<div class="row">Settings</div>
</div>
<div class="detail-panel" style="view-transition-name: active-row">
<!-- detail content appears here -->
</div>
function showDetail(row: Element) {
document.startViewTransition(() => {
gridPanel.hidden = true;
detailPanel.hidden = false;
});
}

Both elements share the name active-row, so the browser animates between them. Notice the grid panel has overflow: hidden, which is important for what comes next.

The overflow escape

The problem shows up when the transitioning element lives inside a container with overflow: hidden, something pretty much every production UI has.

Click a row to trigger a transition
DashboardMain overview
AnalyticsUsage metrics
SettingsConfiguration
BillingPayment info
Adjacent panel content

When you call startViewTransition(), the browser captures old and new states as snapshots, then paints them as pseudo-elements on a layer above the document. But those pseudo-elements don’t respect the clipping boundaries of the elements they came from. The snapshot escapes the container and floats over content it was never supposed to touch.

The dashed red border in the demo is where overflow: hidden should be constrained. Everything outside it is layout that other components own. So you end up with overlapping toolbars, sibling panels, or spilling over content you never meant to touch.

The fix

The fix is to temporarily relax the clipping. Right before the transition starts, you walk up the ancestor chain and flip every overflow: hidden container to visible. When the transition finishes, you restore them.

Click a row to trigger a transition
DashboardMain overview
AnalyticsUsage metrics
SettingsConfiguration
BillingPayment info
overflow: hidden
Adjacent panel content

Here’s what that looks like:

function collectOverflowAncestors(node: Element): HTMLElement[] {
const result: HTMLElement[] = [];
let current = node.parentElement;
while (current) {
const style = getComputedStyle(current);
if (style.overflow !== 'visible' || style.overflowX !== 'visible' || style.overflowY !== 'visible') {
result.push(current);
}
current = current.parentElement;
}
return result;
}
type Cleanup = () => void;
function relaxClipping(node: Element): Cleanup {
const ancestors = collectOverflowAncestors(node);
const previous = ancestors.map((el) => ({
el,
overflow: el.style.overflow,
overflowX: el.style.overflowX,
overflowY: el.style.overflowY,
}));
for (const { el } of previous) {
el.style.overflow = 'visible';
el.style.overflowX = 'visible';
el.style.overflowY = 'visible';
}
return () => {
for (const item of previous) {
item.el.style.overflow = item.overflow;
item.el.style.overflowX = item.overflowX;
item.el.style.overflowY = item.overflowY;
}
};
}

If that doesn’t sound tricky enough, I haven’t got to the best part: you still have to make sure cleanup runs. Transitions can get canceled, pages can swap mid-animation, and if you forget to restore the clipping you’ve swapped a visual glitch for a layout bug that sticks around.

The transition object gives you what you need. The ViewTransition that startViewTransition() returns exposes a few promises that map to its lifecycle:

  1. updateCallbackDone: settles when the DOM swap finishes.
  2. ready: settles when the browser has built the pseudo-element tree and is about to animate.
  3. finished: settles when the animation is done, or when the transition gets canceled.

That last one is the important hook. By attaching cleanup to finished.finally(), we can restore the clipping whether the transition completes normally or bails halfway through.

function showDetail(row: Element) {
const restoreClipping = relaxClipping(row);
const transition = document.startViewTransition(() => {
gridPanel.hidden = true;
detailPanel.hidden = false;
});
transition.finished.finally(() => {
restoreClipping();
});
}

For cross-document navigations, the platform gives you pageswap on the old page and pagereveal on the new page. If a view transition is happening, each event exposes a viewTransition for that phase, so you still get lifecycle hooks, just not one shared object crossing the navigation.

Handling accessibility

There’s one more thing the raw API doesn’t handle for you automatically. If a user has prefers-reduced-motion: reduce enabled, you still need to decide how your app handles it. You can do that in JavaScript or in CSS by disabling the captured names or animations. The important part is that the primitive won’t make that call for you.

Since you’re already wrapping the API for clipping, it makes sense to fold this in too. In practice I’d combine the clipping fix and the motion check into a single wrapper:

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function safeViewTransition(
node: Element,
update: () => void | Promise<void>,
): Promise<void> {
if (!document.startViewTransition || reduceMotion.matches) {
return Promise.resolve(update()).then(() => undefined);
}
const restoreClipping = relaxClipping(node);
const transition = document.startViewTransition(() => update());
return transition.finished
.finally(() => {
restoreClipping();
})
.then(() => undefined);
}

Wrapper up

The View Transition API is powerful, but like most primitives, it’s raw by default. If you’re using it across a codebase it’s probably best to add a small wrapper around it so you can get all the details right. If hooks change, or new behavior is added, you swap them in one place or remove what’s no longer needed.