View Transitions Experiment: Automatically triggered View Transitions with MutationObserver

Instead of adding document.startViewTransition at various places in your JS, use a MutationObserver to watch for DOM mutations. In the Observer’s callback undo the original mutation and reapply it, but this time wrapped in a View Transition.

~

🧪👨‍🔬 This post is about a CSS/JS Experiment.

Even though the resulting code does reach the intended goal, don’t forget that it is hacked together – you might not want to use this in production.

~

# The need for automatic View Transitions

Today on BlueSky, Cory LaViska wondered the following about Same-Document View Transitions:

This is a very valid feature request, and also something that my colleague Adam and I have needed ourselves before. Check the following demo which triggers a View Transition in response to a radio button being checked.

In order to make it work, you need to hijack the radio selection, undo it, and then re-apply it but this time wrapped in a View Transition.

The code powering this is the following:

document.querySelectorAll('.item input').forEach(($input) => {
	// @note: we listen on click because that happens *before* a change event.
	// That way we can prevent the input from getting checked, and then reapply
	// the selection wrapped in a call to `document.startViewTransition()`
	$input.addEventListener('click', async (e) => {
		if (!document.startViewTransition) return;

		e.preventDefault();

		document.startViewTransition(() => {
			e.target.checked = true;
		});
	});
});

Like Cory said, it would be nice if this worked without any extra code. What if you didn’t need to hijack the click event nor needed to riddle your JS-logic with calls to document.startViewTransition, but had something that allows you to say: “When this changes, do it with a Same-Document View Transition”? That’d be very nice, easy, and robust.

💡 This feature is something that is on the Chrome team’s back-of-the-backlog. We are roughly thinking of a CSS property or something like that to opt-in to it, and are tentatively calling this potential feature “Declarative View Transitions”. Don’t get your hopes up for this just yet, as there are a bunch of other features – not specifically related to View Transitions – that have a much higher priority.

~

# Auto-trigger a View Transition with MutationObserver

Sparked by Cory’s request I created a POC that tries to give an answer to the problem. The starting point I used is the following demo which allows you to add and remove cards to a list.

Without View Transitions, the core of that demo is the following:

document.querySelector('.cards').addEventListener('click', e => {
	if (e.target.classList.contains('delete-btn')) {
			e.target.parentElement.remove();
	}
})

document.querySelector('.add-btn').addEventListener('click', async (e) => {
	const template = document.getElementById('card');
	const $newCard = template.content.cloneNode(true);
	$newCard.firstElementChild.style.backgroundColor = `#${ Math.floor(Math.random()*16777215).toString(16)}`;
	document.querySelector('.cards').appendChild($newCard);
});

Instead of adjusting the code above to include View Transitions – as I have done in the previous embed – I resorted to adding a MutationObserver to the code. The MutationObserver is used to execute a callback when it observes a DOM change. In the callback I have it set to automatically undo+reapply the mutation that was done. For example, when a card gets added to the list, I immediately remove that newly added element and then re-add it wrapped in document.startViewTransition. This works because MutationObserver callbacks are queued as microtasks, which can block rendering.

const observer = new MutationObserver(async (mutations) => {
	for (const mutation of mutations) {
		// A node got added
		if (mutation.addedNodes.length) {
			const $li = Array.from(mutation.addedNodes).find(n => n.nodeName == 'LI');
		
			// …

			// Undo the addition, and then re-added it in a VT
			$li.remove();
			const t = document.startViewTransition(() => {
				mutation.target.insertBefore($li, mutation.nextSibling);
			});

			// …
		}
	}
});
observer.observe(document.querySelector('.cards'), {
	childList: true,
	characterData: false,
});

With that code in place, the snapshotting process for View Transitions is able to capture the old and new states properly when a card was added: one without the newly added element and one with the newly added element.

A similar thing is done for a card being removed: it immediately gets re-added, and only then it gets removed through a View Transition.

Also in place is some logic to prevent the MutationObserver callback from looping over itself. This would happen because the call to $li.remove(); in the callback triggers a new mutation to happen. For this I set and unset an isLocked variable. It gets set to true when a callback is processing things and gets set back to false after the View Transition’s snapshots have been taken by awaiting the ready promise of the ViewTransition object.

Combined, the result is this:

~

# Not tackled by MutationObserver

Not tackled in this POC are changes like radio buttons getting checked. This because those changes are not changes that are observable by a MutationObserver: it is a JavaScript property of the element that changes, not a content attribute.

To tackle that use-case, I resorted to using my StyleObserver which can trigger an observable change when the checkbox/radio changes to :checked.

input:checked {
  --vt-checked: ON; /* 👈 This custom property gets observed with StyleObserver */
}

Problem there, though, is that – in Chrome – the StyleObserver’s callback gets fired too late (as in: after a new frame has already been produced) resulting in a 1-frame glitch. See the following embed, in which I adjusted Adam’s Bento demo to use @bramus/style-observer to trigger the View Transition: If you look closely you’ll see the new state flash by before the View Transition runs.

# 🕵️‍♂️ The 1-frame glitch issue in Chrome explained

When recording a performance trace with screenshots in DevTools – which you can check on trace.cafe – the filmstrip clearly shows the 1 glitchy frame in which the endstate is already visible:

Screenshot filmstrip from a Chrome DevTools Performance Trace. The glitchy frame is selected in the timeline. The screenshot in the filmstrip just above clearly shows the pink box being the big one, while that should be part of the transition.

What I believe is going on, is that Blink (Chrome’s engine) delays the start of the transition until the next frame. This can be seen when checking the details of the timeline:

Screenshot of a Chrome DevTools Performance Trace. Almost right after the click the transitions get queued (the purple bar) but their transitionstart only first after the next frame was already produced. This causes a delay of 12.35ms in which nothing happens.

For reference here’s a trace of Safari done with its Web Inspector. In Safari, everything happens in a split second – without any delay at all:

Screenshot of a Safari Web Inspector timeline recording. From the timeline a subsection of 11.27ms is selected, which is more than enough for Safari to handle everything (from click to vt.ready).

The same issue in Chrome happens when using transitionrun. It too fires only in the next frame.

UPDATE 2025.05.06: This bug has been confirmed by Blink Engineering. While there are good reasons to delay transitionstart until the next frame, there are no such reasons to do so for transitionrun. However, the spec is a bit vague about this, so a spec issue has been filed.

Ideally, either Blink/Chrome would change things by not holding the transitions for an extra frame, or we’d need a StyleObserver that triggers before rendering, or we’d something like an extension to MutationObserver that also allows you to monitor property changes.

Also not covered are batched updates – such as elements getting removed in once place and added in a new place. In the demo above I have worked around this by manually grouping the mutations into pairs before handling them as one changeset.

UPDATE 2024.12.25 – Here’s a follow-up post that tackles this issue by syncing the IDL attributes to Content Attributes, allowing me to use the IntersectionObserver after all to handle this: https://brm.us/auto-transitions-2

~

# Spread the word

Feel free to repost one of my posts on social media to give them more reach, or link to the blogpost yourself one way or another 🙂

~

Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Join the Conversation

2 Comments

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.