if()
function inside CSS Custom Functions make @function
very powerful.]]>In https://brm.us/css-custom-functions I took a first look at Chrome’s prototype of Custom Functions (CSS @function
). Since then the prototype in Chrome got updated with nested container queries support and CSS if()
also got added … and like I said: it’s a game changer
~
⚠️ This post is about an upcoming CSS feature. You can’t use it … yet.
This feature is currently being prototyped in Chrome Canary and can be tested in Chrome Canary with the Experimental Web Platform Features flag enabled.
~
light-dark()
that works with any value.The function I built in https://brm.us/css-custom-functions is a custom --light-dark()
that can be used to return values depending on whether light or dark mode is being used.
@function --light-dark(--light, --dark) {
result: var(--light);
@media (prefers-color-scheme: dark) {
result: var(--dark);
}
}
Unlike the built-in light-dark()
, this custom function is not limited to <color>
values and works with any type of value. But also unlike light-dark()
it cannot respond to the local color-scheme
value and can only respond to the light/dark media preference.
See the Pen
Custom CSS Custom Functions: –light-dark() by Bramus (@bramus)
on CodePen.
As hinted at the end of the post, this limitation can be removed once support for nested container queries and/or CSS if()
got added to Chrome … and that day has come!
~
--light-dark()
using Container Queriesℹ️ Because this code uses container queries you always need a wrapper element. The next section that uses if()
does not need this extra wrapper element.
Since my previous post the prototype in Chrome got expanded to also support nested container queries inside custom functions. This opens the path to allowing a per-element light/dark preference, like so:
--scheme
--light-dark()
to use a style query to respond to the value of --scheme
The possible values for --scheme
are light
, dark
, and system
. When --scheme
is set to one of the first two, the color-scheme
is forced to that value. When set to system
.
The function looks like this:
@function --light-dark(--light, --dark) {
/* Default to the --light value */
result: var(--light);
/* If the container is set to "dark", use the --dark value */
@container style(--scheme: dark) {
result: var(--dark);
}
/* If the container is set to "system" and the system is set to "dark", use the --dark value */
@container style(--scheme: system) {
@media (prefers-color-scheme: dark) {
result: var(--dark);
}
}
}
Inside the @function
, the --light
and --dark
values are passed in as arguments to the function. The --scheme
custom property however is read from the element on which the function is invoked.
To ensure that there is some value for --scheme
, I set it on the :root
depending on the prefers-color-scheme
value. The value is also duplicated into a --root-scheme
for further use.
:root {
--root-scheme: light;
--scheme: light;
@media (prefers-color-scheme: dark) {
--root-scheme: dark;
--scheme: dark;
}
}
To allow setting a preferred color scheme on a per-element basis, I resorted to using a data-scheme
HTML attribute which I parse to a value in CSS using attr()
. When the value is light
or dark
I use the value directly. When the value is system
, the code uses the --root-scheme
property value. To play nice with nested light/dark contexts the code uses @scope
.
/* Allow overriding the --scheme from the data-scheme HTML attribute */
@scope ([data-scheme]) {
/* Get the value from the attribute */
:scope {
--scheme: attr(data-scheme type(<custom-ident>));
}
/* When set to system, use the --root-scheme value (which is determined by the MQ) */
:scope[data-scheme="system"] {
--scheme: var(--root-scheme);
}
/* This allows the native light-dark() to work as well */
:scope > * {
color-scheme: var(--scheme);
}
/* Because I chose to use these elements as extra wrapper elements, I can just display its contents */
display: contents;
}
To learn about this attr()
, go read CSS attr()
gets an upgrade. As for @scope
, it’s sufficient to read the quick intro on @scope
.
With all pieces in place it’s time to use it.
In CSS:
[data-scheme] > * {
color: light-dark(#333, #e4e4e4);
background-color: light-dark(aliceblue, #333);
border: 4px --light-dark(dashed, dotted) currentcolor;
font-weight: --light-dark(500, 300);
font-size: --light-dark(16px, 18px);
transition: all 0.25s ease, border-style 0.25s allow-discrete;
}
In HTML:
<div data-scheme="light">
…
</div>
Here’s a live demo. Remember that you need Chrome Canary with the Experimental Web Platform Features Flag to see the code in action.
See the Pen
Custom CSS Custom Functions + Nested Style Queries (+ attr()): –light-dark() by Bramus (@bramus)
on CodePen.
~
--light-dark()
using Inline if()
As of Chrome Canary 135.0.7022.0 the inline if()
is also available behind the Experimental Web Platform Features flag. With this function you can omit the extra container element that the container queries approach needs, as you can conditionally select a value directly in a declaration.
The if()
function also accepts style queries, so the overall approach remains the same: use a custom property and respond to its value. The resulting code however is much much shorter:
@function --light-dark(--light, --dark) {
result: if(style(--scheme: dark): var(--dark); else: var(--light));
}
The code to set --scheme
to light
or dark
also is shorter, as it’s more easy to fall back to the --root-scheme
value.
:root {
--root-scheme: light;
--scheme: light;
@media (prefers-color-scheme: dark) {
--root-scheme: dark;
--scheme: dark;
}
}
@scope ([data-scheme]) {
:scope {
--scheme-from-attr: attr(data-scheme type(<custom-ident>));
--scheme: if(
style(--scheme-from-attr: system): var(--root-scheme);
else: var(--scheme-from-attr)
);
color-scheme: var(--scheme); /* To make the native light-dark() work */
}
}
Usage remains the same as before, with the difference that you can set the color-scheme dependent styles directly on the [data-scheme]
element.
[data-scheme] {
color: light-dark(#333, #e4e4e4);
background-color: light-dark(aliceblue, #333);
border: 4px --light-dark(dashed, dotted) currentcolor;
font-weight: --light-dark(500, 300);
font-size: --light-dark(16px, 18px);
transition: all 0.25s ease, border-style 0.25s allow-discrete;
}
Here’s a live demo to check out:
See the Pen
Custom CSS Custom Functions + Nested inline if() (+ attr()): –light-dark() by Bramus (@bramus)
on CodePen.
~
I was already very much excited about CSS Custom Functions by itself. Combining it with inline if()
takes that to even a higher level.
Expressed through the Galaxy Brain (aka Expanding Brain) meme, this is how I feel about this:
~
Feel free to reshare one of the following posts on social media to help spread the word:
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Chrome is currently prototyping CSS Functions, which is very exciting!
~
⚠️ This post is about an upcoming CSS feature. You can’t use it … yet.
This feature is currently being prototyped in Chrome Canary and can be tested in Chrome Canary with the Experimental Web Platform Features flag enabled.
~
Chrome is currently prototyping CSS Functions from the css-mixins-1
specification.
A custom function can be thought of as an advanced custom property, which instead of being substituted by a single fixed value, computes its substitution value based on function parameters and the value of custom properties at the point it’s invoked.
Here’s a very simple example (taken from the spec) that should give you an idea of what a custom function looks like:
@function --negate(--value) {
result: calc(-1 * var(--value));
}
You invoke the function like by calling directly – no need for var()
or the like – and can use it anywhere a value is accepted. For example:
:root {
padding: --negate(1px); /* = -1px */
}
The implementation in Chrome Canary is currently incomplete and there is no shipping date set, but you can already try out the WIP-implementation by enabling the Experimental Web Platform Features flag.
~
I’m very excited about this upcoming feature, as it will open up a lot of possibilities, way more impactful than that --negate
example from the spec.
For example: a limitation of the CSS light-dark()
function is that it only works with <color>
values. Thanks to Custom Functions you can write your own --light-dark()
that works with any value.
@function --light-dark(--light, --dark) {
result: var(--light);
@media (prefers-color-scheme: dark) {
result: var(--dark);
}
}
If you are visiting the site in the dark mode, the --dark
value will be returned. Otherwise the --light
value gets returned.
For example, you can use this --light-dark()
to have different font-weight
– something Robin suggests on doing:
:root {
color-scheme: light dark;
font-family: "Literata", serif;
color: light-dark(#333, #e4e4e4);
background-color: light-dark(aliceblue, #333);
font-weight: --light-dark(500, 300);
}
Here’s a live demo that uses that code (and which also changes the font-size
and some border
-related properties along with it):
See the Pen Custom CSS Custom Functions: –light-dark() by Bramus (@bramus) on CodePen.
💁♂️ Note that the custom --light-dark()
is not an entire copy of light-dark()
. The built-in light-dark()
can return different values based on the used color-scheme
of an element whereas --light-dark()
relies on the global light/dark preference. Being able to respond to used values is not covered by @function
itself. For that we’d also need the CSS if()
function, which is also in the making (but not really ready for testing, yet). Container queries inside the function also work – to take the parent context into account – but that is currently not prototyped yet.
🏎️ Also note that for <color>
values I am still using the built-in light-dark()
here, but could have used my custom one as well. I have a hunch that the built-in version performs faster, but that would need a proper benchmark.
~
In this post I’ve limited myself to only a basic-ish example, without covering too much details. Not mentioned for example are default values for the function parameters and how to specify the types for any of those. For that, you can dig into the spec. Note that the spec still has a lot of moving parts, as the spec gets influenced by findings from the prototype Chrome is building.
To follow along with Chrome’s progress of the prototype, subscribe to crbug/325504770 by hitting the star next to its title.
~
Feel free to reshare one of the following posts on social media to help spread the word:
~
🔥 Like what you see? Want to stay in the loop? Here's how:
::view-transition-group(*)
don’t change between the old and new snapshot, you can optimize its keyframes so that the pseudo-element animates on the compositor.]]>If the dimensions of the ::view-transition-group(*)
don’t change between the old and new snapshot, you can optimize its keyframes so that the pseudo-element animates on the compositor.
~
🌟 This post is about View Transitions. If you are not familiar with the basics of it, check out this 30-min talk of mine to get up to speed.
~
::view-transition-group()
With View Transitions, the ::view-transition-group()
pseudos are the ones that move around on the screen and whose dimensions get adjusted as part of the View Transition. You can see this in the following visualization when hovering the browser window:
See the Pen
View Transition Pseudos Visualized (2) by Bramus (@bramus)
on CodePen.
The keyframes to achieve this animation are automatically generated by the browser, as detailed in step 3.9.5 of the setup transition pseudo-elements algorithm.
Set capturedElement’s group keyframes to a new CSSKeyframesRule representing the following CSS, and append it to document’s dynamic view transition style sheet:
@keyframes -ua-view-transition-group-anim-transitionName { from { transform: transform; width: width; height: height; backdrop-filter: backdropFilter; } }
Note: There are no to
keyframes because the relevant ::view-transition-group
has styles applied to it. These will be used as the to values.
~
While this all works there one problem with it: the width
and height
properties are always included in those keyframes, even when the size of the group does not change from its start to end position. Because the width
and height
properties are present in the keyframes, the resulting animation runs on the main thread, which is typically something you want to avoid.
Having UAs omit the width
and height
from those keyframes when they don’t change could allow the animation to run on the compositor, but OTOH that would break the predictability of things. If you were to rely on those keyframes to extract size information and the info was not there, your code would break.
TEASER: Some of the engineers on the Blink team have pondered about an optimization in which width
and height
animations would be allowed to run on the compositor under certain strict conditions. One of those conditions being that the width
and height
values don’t change between start and end. This optimization has only been exploratory so far, and at the time of writing there is no intention to dig deeper into it because of other priorities.
~
In a previous post I shared how you can get the old and new positions of a transitioned element yourself. This is done by calling a getBoundingClientRect
before and after the snapshotting process.
const rectBefore = document.querySelector('.box').getBoundingClientRect();
const t = document.startViewTransition(updateTheDOMSomehow);
await t.ready;
const rectAfter = document.querySelector('.box').getBoundingClientRect();
With this information available, you can calculate the delta between the start and end positions and create your own FLIP keyframes for use with transform
in order to move the group around..
const flip = [
`translate(${rectBefore.left}px,${rectBefore.top}px)`,
`translate(${rectAfter.left}px,${rectAfter.top}px)`,
];
const flipKeyframes = {
transform: flip,
easing: "ease",
};
As an alternative, instead of overwriting the transform
property in the new keyframes you can target the translate
property. However, in that case you need counteract the end transform
value set on the pseudo because the translate
property does not undo the transform
– they stack together.
const flip = [
`${(rectBefore.left - rectAfter.left)}px ${(rectBefore.top - rectAfter.top)}px`,
`0px 0px`,
];
const flipKeyframes = {
translate: flip,
easing: "ease",
};
Regardless of how you generate the new keyframes, you can now set them on the group by sniffing out the relevant animation and updating the effect’s keyframes.
const boxGroupAnimation = document.getAnimations().find((anim) => {
return anim.effect.target === document.documentElement &&
anim.effect.pseudoElement == '::view-transition-group(box)';
});
boxGroupAnimation.effect.setKeyframes(flipKeyframes);
Because the new keyframes don’t include the width
and height
properties, these animations can now run on the compositor 🙂
You could wonder why the following is not sufficient:
const keyframes = boxGroupAnimation.effect.getKeyframes()
delete keyframes[0].width;
delete keyframes[1].width;
delete keyframes[0].height;
delete keyframes[1].height;
boxGroupAnimation.effect.setKeyframes(keyframes);
Problems here are that:
Check out this section from the post on how to get the positions for more details.
~
In the following demos the technique detailed above is used.
Here’s the version that uses translate
(standalone version here):
See the Pen
Better performing View Transition Animations, attempt #2, simplified by Bramus (@bramus)
on CodePen.
(Instructions: click the document to trigger a change on the page)
And here’s the version that uses transform
(standalone version here):
See the Pen
Better performing View Transition Animations, attempt #2, simplified by Bramus (@bramus)
on CodePen.
(Instructions: click the document to trigger a change on the page)
~
In the following demo the default generated animation and my FLIP-hijack version are shown side-by-side so that you can compare how both perform.
See the Pen Regular and Better performing View Transition Animations side-by-side by Bramus (@bramus)
on CodePen.
Especially on mobile devices the results are remarkable. To exaggerate the effect, the page also adds jank by blocking the main thread for 1 second every 5 seconds.
~
When the width
and/or height
of new and old snapshots are different, you can calculate a scale
transform that needs to be used. You set this scale only on the from
keyframe:
const flip = [
`translate(${rectBefore.left}px,${rectBefore.top}px) scaleX(${rectBefore.width / rectAfter.width}) scaleY(${rectBefore.height / rectAfter.height})`,
`translate(${rectAfter.left}px,${rectAfter.top}px) scaleX(1) scaleY(1)`,
];
But for this to work properly you must not forget to set the transform-origin
on the animation (in order to prevent jumps) and to also do some sizigin/clipping magic on the pseudos (to play nice with changing aspect ratios)
const flipKeyframes = {
transform: flip,
transformOrigin: ['0% 0%', '0% 0%'],
easing: "ease",
};
::view-transition-new(*),
::view-transition-old(*) {
width: 100%;
height: 100%;
object-fit: fill;
}
Here’s a live demo that has it all put together:
See the Pen
Better performing View Transition Animations, attempt #2, simplified (using transform, including scale animation) by Bramus (@bramus)
on CodePen.
~
🔥 Like what you see? Want to stay in the loop? Here's how:
::view-transition
root overlay captures all clicks … but you can undo that.]]>~
🌟 This post is about View Transitions. If you are not familiar with the basics of it, check out this 30-min talk of mine to get up to speed.
~
When using View Transitions you’ll notice the page becomes unresponsive to clicks while a View Transition is running.
Take the following example that allows you to manipulate a list of cards. The addition and removal of cards is animated using View Transitions. What you will notice, is that you can’t rage click the +
button to add a lot of cards: the button does not respond to clicks while there is an ongoing View Transition:
See the Pen
Add/Remove Cards with View Transitions (using view-transition-class ) by Bramus (@bramus)
on CodePen.
This happens because of the ::view-transition
pseudo element – the one that contains all animated snapshots – gets overlayed on top of the document and captures all the clicks.
::view-transition /* 👈 Captures all the clicks! */
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root)
└─ ::view-transition-new(root)
To make clicks fall through to the document underneath, you need to disable pointer events on the pseudo. In CSS it’s as easy as including the following rule:
::view-transition {
pointer-events: none;
}
The embed below has the snippet included. As you see, you can rapidly click the +
button multiple times in succession:
See the Pen
Add/Remove Cards with View Transitions (using view-transition-class ) by Bramus (@bramus)
on CodePen.
~
One thing I didn’t mention with the demo above is that there’s an extra thing that I did: I prevented the :root
element from being captured as part of the View Transition.
:root {
view-transition-name: none;
}
The reason for doing so is because snapshots that are being animated do not respond to hit testing, as per spec:
Elements participating in a transition need to skip painting in their DOM location because their image is painted in the corresponding ::view-transition-new() pseudo-element instead. Similarly, hit-testing is skipped because the element’s DOM location does not correspond to where its contents are rendered.
By not capturing the :root
, we leave room for click-interactivity, as clicks then can fall through to the document – as long as you don’t click any of the other snapshots.
And oh, in case you are wondering why you can’t undo this unresponsiveness on the groups, it is for very good reasons:
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Back in December I joined Kevin J Powell on his channel to talk about Cross-Document View Transitions. The video got published this week:
Cross-document, or MPA, view transitions are here, and they are amazing! But there is a lot going on with them, and as I was researching them, I kept seeing that it was Bramus who had great content about them… so I asked if he could teach me more about them and he said yes!
You can find a bunch of my demos over at https://view-transitions.chrome.dev/.
]]>content
– and it can parse values into data types other than <string>
.]]>~
A feature shipping in Chrome 133 – which goes stable on Feb 4 – is a more powerful attr()
. From Chrome 133 onwards you use attr()
with any CSS property — not just content
– and it can parse values into data types other than <string>
.
~
Below is a simple example that parses the data-clr
attribute into a <color>
.
<style>
div {
color: attr(data-clr type(<color>), red);
}
</style>
<div data-clr="blue">My text color is blue</div>
<div>My text color is red (the fallback value)</div>
It’s a basic example that should give you an idea of what the function does.
~
Here is a more on-point example that uses attr()
to parse an attribute into a <custom-ident>
. Handy for View Transitions when you’ve already given the elements a unique id
in the markup: you simply take that id
and use that as the value for the view-transition-name
.
<style>
.card[id] {
view-transition-name: attr(id type(<custom-ident>), none); /* card-1, card-2, card-3, … */
view-transition-class: card;
}
</style>
<div class="cards">
<div class="card" id="card-1"></div>
<div class="card" id="card-2"></div>
<div class="card" id="card-3"></div>
<div class="card" id="card-4"></div>
</div>
For you as a web developer this means that instead of needing 100 declarations (and rules to match the elements) for 100 elements, you can dial that down to just 1. Easy peasy.
Try it out in the following embed:
~
Supporting the (upcoming) release of this more powerful attr()
function I published a blog post on the Chrome for Developers blog and also updated MDN:
~
🔥 Like what you see? Want to stay in the loop? Here's how:
moveBefore
method preserves the element’s state!]]>~
moveBefore
?New in Chrome 133 (which goes stable on Feb 4) is a new method to move an element around the DOM: Node.prototype.moveBefore
. While small in size, this method is a very big deal because it preserves the element’s state when moving them around! For example:
~
The classic way of moving things around is to use Node.prototype.insertBefore
. When doing so the node that was “moved” reloads with its initial state.
document.querySelector('#classic').addEventListener('click', () => {
const $newSibling = getRandomElementInBody();
const $iframe = document.querySelector('iframe');
document.body.removeChild($iframe);
document.body.insertBefore($iframe, $newSibling);
});
Even if you leave out the call to removeChild
, the insertBefore
will still do that all by itself – even if the target is still connected to a parent. I’ve left it in to make what is going on behind the scenes more explicit.
The new way of moving elements around is to use moveBefore
. Syntax-wise the method is modeled after insertBefore
so that you can swap things out easily.
document.querySelector('#classic').addEventListener('click', () => {
const $newSibling = getRandomElementInBody();
const $iframe = document.querySelector('iframe');
document.body.moveBefore($iframe, $newSibling);
});
With moveBefore
the state of the moved element is preserved.
~
Here’s a demo that includes both approaches.
See the Pen DOM State-Preserving Move (Node.prototype.moveBefore) by Bramus (@bramus) on CodePen.
If your browser does not support moveBefore
, check out this video to see it in action. The YouTube embed – which is an iframe – keeps playing as the iframe gets moved around.
~
MutationObserver
and Web ComponentsIf you have a MutationObserver
, using moveBefore
will – just like insertBefore
– generate two mutations: one for the removal and one for adding the element onto its new parent. This choise was made for compatibility reasons.
When using Web Components the connectedMoveCallback
method will fire if you have specified it. If you do not have specified a connectedMoveCallback
method, the regular disconnectedCallback
and connectedCallback
will fire (again for backwards compat reasons) with isConnected
being true
.
~
Browser support is limited to Chrome 133+ at the time of writing. Both Safari and Firefox have expressed (1, 2) their support for this new API.
You can feature detect availability of moveBefore
as follows:
if (!("moveBefore" in Element.prototype)) {
// Not supported
}
~
Feel free to reshare one of the following posts on social media to help spread the word:
~
🔥 Like what you see? Want to stay in the loop? Here's how:
getBoundingClientRect
before and after the snaphots are taken, you can get the before and after positions of the transtioned elements.]]>~
🌟 This post is about View Transitions. If you are not familiar with the basics of it, check out this 30-min talk of mine to get up to speed.
~
::view-transition-group()
With View Transitions, the ::view-transition-group()
pseudos are the ones that move around on the screen and whose dimensions get adjusted as part of the View Transition. You can see this in the following visualization when hovering the browser window:
See the Pen
View Transition Pseudos Visualized (2) by Bramus (@bramus)
on CodePen.
~
While the View Transition API does not immediately expose these old and new positions, you can get them by measuring the animated subject using getBoundingClientRect
.
By calling getBoundingClientRect
before you start the View Transition, you can get the old position and size. To get the new position and size, call getBoundingClientRect
again but this time after the View Transition’s Update Callback has been done. For this you need to await the updateCallbackDone
promise.
// The subject
const $box = document.querySelector('.box');
// Get old position and size
const rectBefore = $box.getBoundingClientRect();
// Start a View Transition that alters the $box in some way
const t = document.startViewTransition(() => {
modify($box);
});
// Wait for the update callback to be done
await t.updateCallbackDone;
// Get the new position and size
const rectAfter = $box.getBoundingClientRect();
Once you have those boxes – measured against the viewport – you do whatever you want with them.
~
In the following demo I simply draw them on screen for debugging purposes.
See the Pen
Debugging View Transitions: Keeping track of the old and new position by Bramus (@bramus)
on CodePen.
~
Another approach you think you might be able to take – but one that I will debunk in this section – is to read the old and new positions from the ::view-transition-group()
s keyframes.
In theory, you should be able to extract the required numbers from the relevant ::view-transition-group()
’s keyframes using the code I shared in the previous post.
// Get all animations linked to the active View Transition
const vtAnimations = document.getAnimations().filter((anim) => {
return anim.effect.target === document.documentElement &&
anim.effect.pseudoElement?.startsWith("::view-transition")
});
// Get the animation linked to '::view-transition-group(box)'
const boxGroupAnimation = vtAnimations.find((anim) => {
return anim.effect.pseudoElement == '::view-transition-group(box)';
});
// Get the keyframes
const boxKeyframes = boxGroupAnimation.effect.getKeyframes();
// Extract the from and to positions
// Both values are in the format `matrix(a, b, c, d, tx, ty)`
// You need the tx and ty values
const from = boxKeyframes[0].transform;
const to = boxKeyframes[1].transform;
However, this approach turns out to be unworkable because of two reasons:
The computed keyframes have bugs in both Blink and WebKit, so you can’t rely on them.
to
keyframe: all values for all properties in this generated frame are initial values for those properties – crbug/387030974from
keyframe: its values are the same as the generated to
keyframe – https://bugs.webkit.org/show_bug.cgi?id=285277The exposed offsets are relative to the “Snapshot Containing Block”, not the Viewport.
The Snapshot Containing Block is a rectangle that is used to position the ::view-transition
pseudo and its descendants.
This rectangle includes the address bar – if any – so its origin can be different from the Layout Viewport’s origin on mobile devices.
As a result, the tx
and ty
values can’t be used directly if the Snapshot Containing Block’s origin and Viewport’s origin differ.
See the following screenshot of Chrome on Android. The box is positioned at the offset 24,24 when measured against the Viewport, but that has become 24,80 when measured against the Snapshot Containing Block.
As there is no way to know the position of the viewport relative to the Snapshot Containing Block, you can’t always be sure if the number represents the viewport-relative position or not. It does on desktop. It does on mobile with the navigation bar retracted. But it does not on mobile with the navigation bar expanded … except in Safari because they have not implemented the Snapshot Containing Block – https://bugs.webkit.org/show_bug.cgi?id=285400.
I have filed an issue with the CSS Working Group to normalize the exposed keyframes to hold viewport-relative coordinates: w3c/csswg-drafts#11456.
~
🔥 Like what you see? Want to stay in the loop? Here's how:
~
🌟 This post is about View Transitions. If you are not familiar with the basics of it, check out this 30-min talk of mine to get up to speed.
~
With this snippet you can get all animations that are linked to an active View Transition:
const vtAnimations = document.getAnimations().filter((anim) => {
return anim.effect.target === document.documentElement &&
anim.effect.pseudoElement?.startsWith("::view-transition")
});
This gets all animations for the document and filters out those that are linked to a pseudo element of document.documentElement
whose name starts with "::view-transition"
~
You run this code after you have started the transition and after the snapshots have been taken:
// Start a View Transition
const t = document.startViewTransition(…);
// Wait for the snapshots to be taken
await t.ready;
// Get all animations
const vtAnimations = document.getAnimations().filter((anim) => {
return anim.effect.target === document.documentElement &&
anim.effect.pseudoElement?.startsWith("::view-transition")
});
~
Once you have the animations, you can do all sorts of things with them: reverse them, change the duration, change the keyframes themselves, etc.
In the following example I get the keyframes for the ::view-transition-group(box)
pseudo and dump them on screen to see what’s going on.
See the Pen
Debugging View Transitions: Getting the group’s animation by Bramus (@bramus)
on CodePen.
The core of the code is the following:
// Get the animation linked to '::view-transition-group(box)'
const boxGroupAnimation = vtAnimations.find((anim) => {
return anim.effect.pseudoElement == '::view-transition-group(box)';
});
// Get the keyframes
const boxKeyframes = boxGroupAnimation.effect.getKeyframes();
👆 This demo is extra interesting because it surfaces bugs with getKeyframes()
for ::view-transition-group()
pseudos in both Blink and WebKit: Blink computes the wrong to
keyframe (bug) and WebKit exposes the wrong from
keyframe (bug).
Or check out this demo by Jake Archibald – where I got the code originally from – that pauses all animations and updates their currentTime
based on the drag distance as you drag the grey circle from side to side.
The result is a Draggable View Transition, but more on that in a later post 😉
(Check out Jake’s demo in Chrome for full effect, as it uses the Navigation API which is not in Safari stable yet)
~
In the future you should be able to simplify the code to the following, thanks to w3c/csswg-drafts#9908.
// 🔮 Code from the future. This does not work in any browser … yet.
const vtAnimations = t.transitionRoot.getAnimations({
pseudoElement: '::view-transition',
subtree: true,
});
It calls getAnimations()
on the transition root directly – handy whenever scoped transitions becomes a thing – and uses the new pseudoElement
option to filter out the ::view-transition
overlay pseudo. With subtree: true
, all children of that pseudo are also included in the resultset.
This code however still needs to be specced and is currently not implemented in any browser. Until then, use the code shared at the top of this post.
~
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:
MutationObserver
into observing changes to IDL attributes by syncing the IDL attributes to their Content Attribute counterparts.]]>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.
~
~
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.
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.
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.
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.
~
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.
See the Pen
Radiento – Bento Radio Group Carousel thing by Bramus (@bramus)
on CodePen.
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:
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.
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.
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.
So the problem I want to solve here is being able to use a MutationObserver
to respond to changes to IDL attributes.
~
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.
~
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.
~
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.
click
click
MutationObserver
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.
~
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:
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.beforechange
which allow you to respond to a state change but before the browser actually updates the state. It would allow you to preventDefault()
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 🙂
~
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: