Bram.us https://www.bram.us A rather geeky/technical weblog, est. 2001, by Bramus Thu, 13 Feb 2025 14:09:43 +0000 en-US hourly 1 https://wordpress.org/?v=6.7.2 CSS Custom Functions are coming … and they are going to be a game changer! https://www.bram.us/2025/02/09/css-custom-functions-teaser/ https://www.bram.us/2025/02/09/css-custom-functions-teaser/#respond <![CDATA[Bramus!]]> Sun, 09 Feb 2025 01:16:35 +0000 <![CDATA[Original Content]]> <![CDATA[css]]> <![CDATA[functions]]> https://www.bram.us/?p=35328 <![CDATA[Chrome is currently prototyping CSS Functions, which is very exciting!]]> <![CDATA[

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

~

Spread the word

Feel free to reshare one of the following posts on social media to help spread the word:

~

]]>
https://www.bram.us/2025/02/09/css-custom-functions-teaser/feed/ 0
View Transitions Applied: More performant ::view-transition-group(*) animations https://www.bram.us/2025/02/07/view-transitions-applied-more-performant-view-transition-group-animations/ https://www.bram.us/2025/02/07/view-transitions-applied-more-performant-view-transition-group-animations/#respond <![CDATA[Bramus!]]> Fri, 07 Feb 2025 19:55:01 +0000 <![CDATA[Original Content]]> <![CDATA[animations]]> <![CDATA[flip]]> <![CDATA[view transitions]]> https://www.bram.us/?p=35307 <![CDATA[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.]]> <![CDATA[

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.

~

# The ::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.

~

# The Problem

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.

~

# The Technique

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 = [
	&grave;translate(${rectBefore.left}px,${rectBefore.top}px)&grave;,
	&grave;translate(${rectAfter.left}px,${rectAfter.top}px)&grave;,
];

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 transformthey stack together.

const flip = [
	&grave;${(rectBefore.left - rectAfter.left)}px ${(rectBefore.top - rectAfter.top)}px&grave;,
	&grave;0px 0px&grave;,
];

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 🙂

🤔 Why not directly manipulate the existing keyframes?

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:

  1. The computed keyframes have bugs in Blink (Chrome), so you can’t rely on them.
  2. The exposed offsets are relative to the “Snapshot Containing Block”, not the Viewport.

Check out this section from the post on how to get the positions for more details.

~

# Demo

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)

~

# Side-by-Side Comparison

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.

~

# What about different sizes?

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.

~

]]>
https://www.bram.us/2025/02/07/view-transitions-applied-more-performant-view-transition-group-animations/feed/ 0
View Transitions Snippets: Keeping the page interactive while a View Transition is running https://www.bram.us/2025/01/29/view-transitions-page-interactivity/ https://www.bram.us/2025/01/29/view-transitions-page-interactivity/#comments <![CDATA[Bramus!]]> Wed, 29 Jan 2025 18:33:32 +0000 <![CDATA[Original Content]]> <![CDATA[css]]> <![CDATA[view transitions]]> https://www.bram.us/?p=35225 <![CDATA[The ::view-transition root overlay captures all clicks … but you can undo that.]]> <![CDATA[

~

🌟 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:

  • While being animated, the location on screen might not match the actual element’s location.
  • What should be done when two different elements are transitioning from the one to the other: you could see the old element screen but then clicks would have to go to the new one?

~

]]>
https://www.bram.us/2025/01/29/view-transitions-page-interactivity/feed/ 2
MPA View Transitions Deep Dive https://www.bram.us/2025/01/26/mpa-view-transitions-deep-dive/ https://www.bram.us/2025/01/26/mpa-view-transitions-deep-dive/#respond <![CDATA[Bramus!]]> Sun, 26 Jan 2025 19:55:08 +0000 <![CDATA[Elsewhere]]> <![CDATA[video]]> <![CDATA[view transitions]]> https://www.bram.us/?p=35284 <![CDATA[Back in December I joined Kevin J Powell on his channel to talk about Cross-Document View Transitions]]> <![CDATA[

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

]]>
https://www.bram.us/2025/01/26/mpa-view-transitions-deep-dive/feed/ 0
CSS attr() gets an upgrade https://www.bram.us/2025/01/20/css-attr-gets-an-upgrade/ https://www.bram.us/2025/01/20/css-attr-gets-an-upgrade/#respond <![CDATA[Bramus!]]> Mon, 20 Jan 2025 18:53:21 +0000 <![CDATA[Elsewhere]]> <![CDATA[css]]> <![CDATA[link]]> https://www.bram.us/?p=35278 <![CDATA[You can now use `attr()` with any CSS property — not just content – and it can parse values into data types other than <string>.]]> <![CDATA[

~

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:

~

]]>
https://www.bram.us/2025/01/20/css-attr-gets-an-upgrade/feed/ 0
Move elements around the DOM while preserving their state with moveBefore https://www.bram.us/2025/01/16/move-elements-around-the-dom-while-preserving-their-state-with-movebefore/ https://www.bram.us/2025/01/16/move-elements-around-the-dom-while-preserving-their-state-with-movebefore/#comments <![CDATA[Bramus!]]> Thu, 16 Jan 2025 11:37:39 +0000 <![CDATA[Original Content]]> <![CDATA[dom]]> <![CDATA[javascript]]> https://www.bram.us/?p=35265 <![CDATA[Unlike the classic way of moving a node by removing+reinserting it, the moveBefore method preserves the element’s state!]]> <![CDATA[

~

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:

  • Iframes remain loaded
  • Active element remains focus
  • Popovers, fullscreen, modal dialogs remain open
  • CSS transitions and animations carry on

~

Old vs new

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.

~

Demo

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.

~

The effect on MutationObserver and Web Components

If 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

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
}

~

Spread the word

Feel free to reshare one of the following posts on social media to help spread the word:

~

]]>
https://www.bram.us/2025/01/16/move-elements-around-the-dom-while-preserving-their-state-with-movebefore/feed/ 1
View Transitions Snippets: Keeping track of the old and new positions of a transitioned element https://www.bram.us/2025/01/08/view-transitions-snippets-keeping-track-of-the-old-and-new-positions-of-a-transitioned-element/ https://www.bram.us/2025/01/08/view-transitions-snippets-keeping-track-of-the-old-and-new-positions-of-a-transitioned-element/#respond <![CDATA[Bramus!]]> Wed, 08 Jan 2025 22:56:14 +0000 <![CDATA[Original Content]]> <![CDATA[animations]]> <![CDATA[javascript]]> <![CDATA[view transitions]]> https://www.bram.us/?p=35230 <![CDATA[By calling getBoundingClientRect before and after the snaphots are taken, you can get the before and after positions of the transtioned elements.]]> <![CDATA[

~

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

~

# The ::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.

~

# Getting the old and new position

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.

~

# Demo

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.

~

# Why not read the keyframes?

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:

  1. The computed keyframes have bugs in both Blink and WebKit, so you can’t rely on them.

  2. The 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.

    Illustration from the spec, showing the Snapshot Containing Block: The snapshot containing block includes the URL bar, as this can be scrolled away. The keyboard is included as this appears and disappears. The top and bottom bars are part of the OS rather than the browser, so they’re not included in the snapshot containing block.

    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.

    Screenshot taken with Chrome on Android. The red outline represents the Layout Viewport. The subject is positioned at 24px from its top edge, indicated by the red arrow. The blue outline represents the Snapshot Containing Block. The subject is is positioned at 80px from its top edge, indicated by the blue arrow.

    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.

  3. ~

    ]]>
https://www.bram.us/2025/01/08/view-transitions-snippets-keeping-track-of-the-old-and-new-positions-of-a-transitioned-element/feed/ 0
View Transitions Snippets: Getting all Animations linked to a View Transition https://www.bram.us/2025/01/01/view-transitions-snippets-getting-all-animations-linked-to-a-view-transition/ https://www.bram.us/2025/01/01/view-transitions-snippets-getting-all-animations-linked-to-a-view-transition/#comments <![CDATA[Bramus!]]> Wed, 01 Jan 2025 21:10:33 +0000 <![CDATA[Original Content]]> <![CDATA[css]]> <![CDATA[javascript]]> <![CDATA[view transitions]]> https://www.bram.us/?p=35207 <![CDATA[A few lines of JavaScript to get all animations linked to an active View Transition.]]> <![CDATA[

~

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

~

# 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 🙂

~

]]>
https://www.bram.us/2025/01/01/view-transitions-snippets-getting-all-animations-linked-to-a-view-transition/feed/ 1
View Transitions Experiment: Automatically trigger a View Transition when a JavaScript Property of an Element changes https://www.bram.us/2024/12/25/experiment-automatically-trigger-a-view-transition-when-a-javascript-property-of-an-element-changes/ https://www.bram.us/2024/12/25/experiment-automatically-trigger-a-view-transition-when-a-javascript-property-of-an-element-changes/#respond <![CDATA[Bramus!]]> Wed, 25 Dec 2024 22:40:44 +0000 <![CDATA[Original Content]]> <![CDATA[mutationobserver]]> <![CDATA[view transitions]]> https://www.bram.us/?p=34991 <![CDATA[Tricking a MutationObserver into observing changes to IDL attributes by syncing the IDL attributes to their Content Attribute counterparts.]]> <![CDATA[

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.

See the Pen
Radiento – Bento Radio Group Carousel thing
by Bramus (@bramus)
on CodePen.

🕵️‍♂️ 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).

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

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');

  // &mldr;
  
  // 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');

  // &mldr;
  
  // 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:

  • 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 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 🙂

~

# 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 🙂

~

]]>
https://www.bram.us/2024/12/25/experiment-automatically-trigger-a-view-transition-when-a-javascript-property-of-an-element-changes/feed/ 0
Misconceptions about View Transitions https://www.bram.us/2024/12/24/misconceptions-about-view-transitions/ https://www.bram.us/2024/12/24/misconceptions-about-view-transitions/#respond <![CDATA[Bramus!]]> Tue, 24 Dec 2024 09:17:55 +0000 <![CDATA[Elsewhere]]> <![CDATA[link]]> <![CDATA[view transitions]]> https://www.bram.us/?p=34679 <![CDATA[Post I wrote a while ago for Chrome for Developers, about some misconceptions about View Transitions that are making rounds.]]> <![CDATA[

Post I wrote a while ago for Chrome for Developers, about some misconceptions about View Transitions that are making rounds.

The View Transition API is a web development game changer. Whether your website is single or multi-page, this powerful API lets you create seamless transitions between views, resulting in native-like experiences that captivate users. Currently available in Chrome, with same document view transitions soon to be available in Safari.

With more and more people starting to look into the View Transition API, it’s time to debunk some misconceptions.

The following misconceptions are covered:

Misconception 1: The View Transition API takes screenshots
→ Nopes, they’re snapshots
Misconception 2: Capturing more than one element results in multiple view transitions running
→ While there are multiple snapshot-pairs in that case, they are all part of the same View Transition.
Misconception 3: You can’t implement view transitions because of browser support
→ Progressive Enhancement is a thing 😉
Misconception 4: View transitions break incremental rendering
→ Nothing has changed there. Browsers still use their existing heuristics to determine when to do a first render.
Misconception 5: The snapshotting process is slow or expensive
→ At most you’ll lose two frames.
Bonus Misconception: It’s the View Transitions [sic] API
→ It’s the View Transition API (singular form)

Especially that bonus misconception is one of my pet peeves. As of last week MDN uses the correct term (issue), so there’re no reason to keep saying the wrong thing 🙂

Read the article →

]]>
https://www.bram.us/2024/12/24/misconceptions-about-view-transitions/feed/ 0