New Dec 25, 2024

Experiment: Automatically trigger a View Transition when a JavaScript Property of an Element changes

More Front-end Bloggers All from Bram.us View Experiment: Automatically trigger a View Transition when a JavaScript Property of an Element changes on bram.us

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

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 with Element.setAttribute(). Sometimes there are extra interfaces available to manipulate these – think of Element.classList that manipulates the class attribute.

IDL Attributes
These are JavaScript properties that you can directly read from or write to the Element using Element.nameOfTheProperty – think of HTMLInputElement.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:

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 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).

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
  1. click
  2. Undo IDL change
  3. Reapply IDL change with a VT
My code
  1. click
  2. Sync to Content Attribute
  3. MutationObserver
  4. Undo IDL change
  5. 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:

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 🙂

~

Scroll to top