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:

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:

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:

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