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.

~

# The need for automatic View Transitions

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

I wish I could opt certain elements in to View Transitions so all DOM modifications would just work without having to wrap with document.startViewTransition() 🤔

— Cory LaViska (@cory.laviska.com) November 25, 2024 at 3:11 AM

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.

See the Pen
Radiento – Bento Radio Group Carousel thing
by Adam Argyle (@argyleink)
on CodePen.

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.

See the Pen
Add/Remove Cards with View Transitions (using view-transition-classs)
by Bramus (@bramus)
on CodePen.

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 callback from blocking rendering indefinitely because the call $li.remove(); done in the callback would trigger a new mutation to happen.

Combined, the result is this:

See the Pen
Automatically triggered View Transitions with MutationObserver
by Bramus (@bramus)
on CodePen.

~

# 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 DOM property of the element that changes, not an attribute. To tackle that use-case, you can use something like my StyleObserver to trigger an observable change when the checkbox/radio changes to :checked. Problem there, though, is that StyleObserver changes fire too late: because the changes fire after rendering, you get a glitch of 1 frame when reapplying the change. See the following embed, in which I adjusted Adam’s Bento demo to use @bramus/style-observer to trigger the View Transition:

See the Pen
Radiento – Bento Radio Group Carousel thing
by Bramus (@bramus)
on CodePen.

Ideally, we’d either need a StyleObserver that triggers before rendering, or 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.

~

# 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

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.