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 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:
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 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
.
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 🙂
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Grande Bramus!