In https://brm.us/auto-transitions I shared a way to automatically run a View Transition triggered by a DOM Mutation. For this I relied on MutationObserver
that kicks into action when the childList
of an element changes.
In the same post I also explored a way to trigger a View Transition when a JavaScript property (IDL Attribute) of an element changes. I tried hacking my way into it using my DIY StyleObserver
but that didn’t work out as it has a visual glitch in Chrome.
In this post I explore an alternative solution that does not have this glitch: by syncing the IDL attribute back to the DOM, I can rely on MutationObserver
– which executes its callbacks before rendering the next frame – to pick up that change.
~
🧪👨‍🔬 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.
~
Table of Contents
- Content Attributes vs IDL Attributes
- The Problem
- The Solution
- The Catch
- The Solution, Revised
- The Future
- Spread the word
~
# Content Attributes vs IDL Attributes
Before digging into the problem and solution, there’s something that you need to know about HTML elements and attributes: there are two types of attributes.
- Content Attributes
-
These are the attributes that are set in the markup. In JavaScript you typically read content attributes with
Element.getAttribute()
and write them withElement.setAttribute()
. Sometimes there are extra interfaces available to manipulate these – think ofElement.classList
that manipulates theclass
attribute. - IDL Attributes
- These are JavaScript properties that you can directly read from or write to the
Element
usingElement.nameOfTheProperty
– think ofHTMLInputElement.value
to read the current value of an<input>
element.
In most cases both types of attributes use the same name and give you the same values, but that’s not always the case: the names can be different, or the returned value can differ. Sometimes manipulating the one gets reflected in the other, but very often changing the one does not affect the other at all.
🤔 Need some examples?
Here’s an example where the IDL Attribute and Content Attribute are mirrored:
<abbr title="Cascading Style Sheets">CSS</abbr>
Reading and writing the Element.title
IDL attribute will affect the title
Content Attribute in the DOM. Same goes in the other direction: after invoking Element.setAttribute('title', 'foo')
, the result for Element.title
will also return that value.
const $abbr = document.querySelector('abbr');
console.log($abbr.getAttribute('title')); // Logs "Cascading Style Sheets"
console.log($abbr.title); // Logs "Cascading Style Sheets"
$abbr.title = 'Counter-Strike: Source';
console.log($abbr.getAttribute('title')); // Logs "Counter-Strike: Source"
console.log($abbr.title); // Logs "Counter-Strike: Source"
This tight coupling between both types of attributes is not always the case. For example in <a href="/about">
the href
content attribute returns "/about"
but when reading the IDL attribute – using HTMLAnchorElement.href
– you get the full URL.
const $link = document.querySelector('a[href="/about"]');
console.log($link.getAttribute('href')); // Logs "/about"
console.log($link.href); // Logs "https://www.bram.us/about"
And in the case of an HTMLInputElement
the both types of attributes have a value
, but changing the one does not affect the other at al. While the IDL Attribute does initially receive its value from the Content Attribute, changing the IDL Attribute does not affect the Content Attribute or vice versa.
const $input = document.querySelector('input[value="1"]');
$input.value = 42;
console.log($input.value); // Logs 42
console.log($input.getAttribute('value')); // Logs 1, not 42
(For completeness: to manipulate the <input>
’s value
Content Attribute from within JavaScript, you need the HTMLInputElement.defaultValue
IDL attribute)
For far more words on this, go read Jake’s post.
~
# The problem
As a reminder, the thing I was trying to solve last time revolved around Adam’s Radiento 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.
While Adam’s code works perfectly fine, my original goal was to keep my code separate from any other existing code: instead of needing to interleave existing code with calls to document.startViewTransition
, I wanted to be able to keep the core logic – if any – in place and complement it with an extra piece solely responsible for handling/triggering the View Transitions.
For DOM Mutations caused by elements getting inserted or removed I successfully used a MutationObserver
to respond to those changes.
Because the “checkedness” of a radio button is tracked in an IDL Attribute, a MutationObserver
won’t do in that situation. However, there is something in the web stack that can perfectly respond to state changes: CSS. For example, an <input>
that is checked gets matched by the :checked
selector. Combining that with my my DIY StyleObserver
I was able to hacked together some code that allows me to monitor IDL Attribute changes from within JavaScript.
While the idea of using StyleObserver
is fine on paper, it doesn’t work properly in Chrome which shows a 1-frame glitch of the new state before actually starting the transition. By the time the StyleObserver
’s callback reverts the checked
state, Chrome has already produced a new frame which includes the new state. This glitch does not happen in Safari, which handles things fine in this situation.
🕵️‍♂️ 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 transition until the next frame. This can be seen when checking the details of the timeline:
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:
I have asked around internally for more details on Blink/Chrome’s behavior here but it’s hard to reach people around this time of the year.
So the problem I want to solve here is being able to use a MutationObserver
to respond to changes to IDL attributes.
~
# The Solution
The solution hack I came up with for using a MutationObserver
to respond to changes to IDL attributes, is to sync the IDL attribute to a Content Attribute.
const $inputs = document.querySelectorAll("#radiento input");
$inputs.forEach(($input) => {
$input.addEventListener("click", async (e) => {
syncCheckedStateToAttribute($inputs, e.target);
});
});
// Sync Checked State to an attribute
const syncCheckedStateToAttribute = (candidates, target) => {
// Don’t run logic when the element was already checked
let targetHasAttribute = target.hasAttribute("checked");
if (targetHasAttribute) return;
// Remove attribute from previously checked element
const prevTarget = Array.from(candidates).find((candidate) =>
candidate.hasAttribute("checked")
);
if (prevTarget) {
prevTarget.removeAttribute("checked");
}
// Set attribute on newly checked element
target.setAttribute("checked", "checked");
};
This way, whenever an input gets checked, the previously checked input gets its [checked]
Content Attribute removed, and the newly checked input gets the [checked]
Content Attribute added.
With the syncCheckedStateToAttribute
code in place, a MutationObserver
can be introduced to respond to these changes. The logic is the same as with the StyleObserver
approach: Get two changes (one for old and one for the new), undo them, and then reapply them but this time with a View Transition.
const observer = new MutationObserver(async (mutations) => {
// Extract mutations to a checked property
const checkedMutations = mutations.filter(m => m.type == 'attributes' && m.attributeName == 'checked');
// …
// Extract changes
const [mutation1, mutation2] = checkedMutations;
// Get newly checked state values
const checked1 = mutation1.target.checked;
const checked2 = mutation2.target.checked;
// Revert back to old state
if (checked1) {
mutation1.target.removeAttribute('checked');
mutation2.target.setAttribute('checked', 'checked');
} else {
mutation1.target.setAttribute('checked', 'checked');
mutation2.target.removeAttribute('checked');
}
// Reapply new state, with View Transition
const t = document.startViewTransition(() => {
if (checked1) {
mutation2.target.removeAttribute('checked');
mutation1.target.setAttribute('checked', 'checked');
} else {
mutation1.target.removeAttribute('checked');
mutation2.target.setAttribute('checked', 'checked');
}
});
});
(Omitted from the code above is some extra logic to lock the MutationObserver
while a set of mutations is already being handled. This is to prevent loops.)
The result is this:
See the Pen
Radiento, using auto-triggered View Transitions (with attribute change + MutationObserver) by Bramus (@bramus)
on CodePen.
~
# The Catch
Because the MutationObserver
manipulates the checked
Content Attribute, there is one big catch which affects how you author your CSS. Instead of using selectors like :checked
– which reflect the element state – you now need to use [checked]
to make sure the View Transitions snapshotting process can capture the correct state.
(This catch is removed in the revised solution detailed further down.)
/* ❌ Can no longer do this */
input:checked {
…
}
/* âś… Need to do this */
input[checked] {
…
}
This changed way to writing CSS can be troublesome when loading in an external library that you have no control over.
Secondly, know that syncing back IDL attributes to Content Attributes poses a risk to leaking data when loading third party CSS. If you accept or load third party CSS, you open your site up for CSS keylogger attacks. A good Content Security Policy can mitigate the risk.
And lastly also note that just like with my previous stab at this, this is a hack because you are effectively undoing and redoing everything. While this does work for things like :checked
it, for example would not work entirely correctly when doing live updates on a input[type=range]
using the input
event.
~
# The Solution, revised
Having to write [checked]
instead :checked
from now on would be very weird and stupid. Thankfully there’s a little tweak to the MutationObserver
’s callback that solves this: instead of undoing+redoing the [checked]
Content Attributes, undo+redo the content
IDL attribute.
const observer = new MutationObserver(async (mutations) => {
// Extract mutations to a checked property
const checkedMutations = mutations.filter(m => m.type == 'attributes' && m.attributeName == 'checked');
// …
// Extract changes and checked states
const [mutation1, mutation2] = checkedMutations;
const checked1 = mutation1.target.checked;
const checked2 = mutation2.target.checked;
// Revert back to old state
mutation1.target.checked = !checked1;
mutation2.target.checked = !checked2;
// Now reapply new state, with a View Transition
window.performance && performance.mark('vt-start');
const t = document.startViewTransition(() => {
mutation1.target.checked = checked1;
mutation2.target.checked = checked2;
});
});
Here’s a demo with the updated code:
See the Pen
Radiento, using auto-triggered View Transitions (with attribute change + MutationObserver + sync back to .checked) by Bramus (@bramus)
on CodePen.
While this code now is cleaner than it was before and also does allow you to use :checked
again, you could start questioning whether it’s a good idea or not. I mean, Adam’s code was three steps, whereas the final code now consists of five steps.
- Adam’s code
-
click
- Undo IDL change
- Reapply IDL change with a VT
- My code
-
click
- Sync to Content Attribute
MutationObserver
- Undo IDL change
- reapply IDL change with a VT.
Thankfully there is no excessive performance impact when using my approach: both approaches take about the same time (±10ms
) to execute. You can check for yourself in the JavaScript console using this fork of Adam’s approach and my approach.
~
# The future
My approach certainly feels like an over-engineered solution at this point. Personally I believe that, ideally, a way to automatically trigger a View Transition when an IDL attribute changes is something that needs to be built into the browser. This could be something like a CSS Property that opts in to triggering the a View Transition when the listed IDL attributes changes. For the checkbox/radio button use-specifically that would be:
/* 💠Just a wild idea … */
input[type="checkbox"], input[type="radio"] {
view-transition-trigger: checked;
}
If not built natively into View Transitions maybe we need some extra pieces in order to make the full puzzle in a more ergonomic way. Some things I am thinking of:
- Allow
MutationObserver
to monitor a limited set of IDL attributes, which would allow early on responding to the state change, allowing you to undo the mutation + redo it with a View Transition. - before-* events, like
beforechange
which allow you to respond to a state change but before the browser actually updates the state. It would allow you topreventDefault()
it, and then apply it yourself wrapped in a View Transition.
Or maybe we need something else. If you can think of something, be sure to let me know in the comments (or file a WG issue and tag me in it).
And oh, whatever the outcome I think Blink/Chrome should fix that transition 1-frame delay issue so that the StyleObserver
approach can become a more solid (intermediary) solution … but I’m not sure right now if that can be done without breaking existing sites.
Until any of the above makes it (if it ever will), then the hack detailed in this post have to do 🙂
~
# 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: