MutationObserver
and document.startViewTransition
love story.]]>Instead of adding document.startViewTransition
at various places in your JS, use a MutationObserver
to watch for DOM mutations. In the Observer’s callback undo the original mutation and reapply it, but this time wrapped in a View Transition.
~
Today on BlueSky, Cory LaViska wondered the following about Same-Document View Transitions:
I wish I could opt certain elements in to View Transitions so all DOM modifications would just work without having to wrap with document.startViewTransition() 🤔
— Cory LaViska (@cory.laviska.com) November 25, 2024 at 3:11 AM
This is a very valid feature request, and also something that my colleague Adam and I have needed ourselves before. Check the following demo which triggers a View Transition in response to a radio button being checked.
See the Pen
Radiento – Bento Radio Group Carousel thing by Adam Argyle (@argyleink)
on CodePen.
In order to make it work, you need to hijack the radio selection, undo it, and then re-apply it but this time wrapped in a View Transition.
The code powering this is the following:
document.querySelectorAll('.item input').forEach(($input) => {
// @note: we listen on click because that happens *before* a change event.
// That way we can prevent the input from getting checked, and then reapply
// the selection wrapped in a call to `document.startViewTransition()`
$input.addEventListener('click', async (e) => {
if (!document.startViewTransition) return;
e.preventDefault();
document.startViewTransition(() => {
e.target.checked = true;
});
});
});
Like Cory said, it would be nice if this worked without any extra code. What if you didn’t need to hijack the click
event nor needed to riddle your JS-logic with calls to document.startViewTransition
, but had something that allows you to say: “When this changes, do it with a Same-Document View Transition”? That’d be very nice, easy, and robust.
💡 This feature is something that is on the Chrome team’s back-of-the-backlog. We are roughly thinking of a CSS property or something like that to opt-in to it, and are tentatively calling this potential feature “Declarative View Transitions”. Don’t get your hopes up for this just yet, as there are a bunch of other features – not specifically related to View Transitions – that have a much higher priority.
~
MutationObserver
Sparked by Cory’s request I created a POC that tries to give an answer to the problem. The starting point I used is the following demo which allows you to add and remove cards to a list.
See the Pen
Add/Remove Cards with View Transitions (using view-transition-classs) by Bramus (@bramus)
on CodePen.
Without View Transitions, the core of that demo is the following:
document.querySelector('.cards').addEventListener('click', e => {
if (e.target.classList.contains('delete-btn')) {
e.target.parentElement.remove();
}
})
document.querySelector('.add-btn').addEventListener('click', async (e) => {
const template = document.getElementById('card');
const $newCard = template.content.cloneNode(true);
$newCard.firstElementChild.style.backgroundColor = `#${ Math.floor(Math.random()*16777215).toString(16)}`;
document.querySelector('.cards').appendChild($newCard);
});
Instead of adjusting the code above to include View Transitions – as I have done in the previous embed – I resorted to adding a MutationObserver
to the code. The MutationObserver
is used to execute a callback when it observes a DOM change. In the callback I have it set to automatically undo+reapply the mutation that was done. For example, when a card gets added to the list, I immediately remove that newly added element and then re-add it wrapped in document.startViewTransition
. This works because MutationObserver callbacks are queued as microtasks, which can block rendering.
const observer = new MutationObserver(async (mutations) => {
for (const mutation of mutations) {
// A node got added
if (mutation.addedNodes.length) {
const $li = Array.from(mutation.addedNodes).find(n => n.nodeName == 'LI');
// …
// Undo the addition, and then re-added it in a VT
$li.remove();
const t = document.startViewTransition(() => {
mutation.target.insertBefore($li, mutation.nextSibling);
});
// …
}
}
});
observer.observe(document.querySelector('.cards'), {
childList: true,
characterData: false,
});
With that code in place, the snapshotting process for View Transitions is able to capture the old and new states properly when a card was added: one without the newly added element and one with the newly added element.
A similar thing is done for a card being removed: it immediately gets re-added, and only then it gets removed through a View Transition. Also in place is some logic to prevent the callback from blocking rendering indefinitely because the call $li.remove();
done in the callback would trigger a new mutation to happen.
Combined, the result is this:
See the Pen
Automatically triggered View Transitions with MutationObserver by Bramus (@bramus)
on CodePen.
~
MutationObserver
Not tackled in this POC are changes like radio buttons getting checked. This because those changes are not changes that are observable by a MutationObserver
: it is a DOM property of the element that changes, not an attribute. To tackle that use-case, you can use something like my StyleObserver
to trigger an observable change when the checkbox/radio changes to :checked
. Problem there, though, is that StyleObserver
changes fire too late: because the changes fire after rendering, you get a glitch of 1 frame when reapplying the change. See the following embed, in which I adjusted Adam’s Bento demo to use @bramus/style-observer
to trigger the View Transition:
See the Pen
Radiento – Bento Radio Group Carousel thing by Bramus (@bramus)
on CodePen.
Ideally, we’d either need a StyleObserver that triggers before rendering, or something like an extension to MutationObserver
that also allows you to monitor property changes.
Also not covered are batched updates – such as elements getting removed in once place and added in a new place. In the demo above I have worked around this by manually grouping the mutations into pairs before handling them as one changeset.
~
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:
Last month I spoke at React Brussels and gave a talk “Supercharge Web UX with View Transitions”
~
~
The talk I gave is a full-length talk of a little over 30 minutes.
Tired of disjointed web apps? View Transitions are the game-changer you’ve been waiting for. Whether your app is single or multi-page, this powerful API lets you create seamless, native-like experiences that captivate users. Join me as I dive into the world of View Transitions, showing you how to replace jarring page loads with elegant transitions. Learn to harness the flexibility of CSS and the power of JavaScript to customize transitions and create a truly unique experience. If you’re ready to take your web apps to the next level, this talk is a must-attend.
~
The slides of my talk are up on slidr.io are embedded below:
These exported slides don’t contain any recordings of the demos included, but you can follow the link to check them out yourself. For the Same-Document View Transitions demos you will need Chrome 111+. For the Cross-Document View Transitions demos you need Chrome 126+.
~
This talk was recorded and is available for you to watch on YouTube. The video is also embedded below:
~
Thanks to Aymen and Elian for inviting me to speak at this wonderful event. It was very well organized and everything – from my POC – went smooth. A pity the turnout wasn’t that great (but definitely not bad too!). It was also very heartwarming to see that not all talks focused on purely React itself and some of its libraries, but that some talks also covered things such as accessibility and progressive enhancement. A well balanced set of talks, delivered by some great speakers, is what I consider a great conference 🙂
~
💁♂️ If you are a conference or meetup organiser, don't hesitate to contact me to come speak at your event.
Yesterday I published a new version of my experimental Chrome Dark Mode Toggle extension. On top of a per-origin override, you can now set a Chrome-wide preference to have your OS in Dark Mode but all sites in Light Mode (or vice versa).
You can get the extension from the Chrome Web Store.
For a backstory behind the why and how of this extension, go read the original announcement post.
]]>Dissecting and reworking a very nice demo by Paul Noble.
~
Paul Noble created an AMAZING scroll-driven animations demo in which you can drag cards from a card stack. As he describes it:
Card stack using scroll-driven animation w/ snapping. Just a few lines of JS, zero dependencies.
Try it out (in Chrome) right here:
See the Pen
Scroll-driven animated card stack with scroll snap events. by Paul Noble (@paulnoble)
on CodePen.
The logic/math used for the stack is based on this thread by Nate Smith
~
💁♂️ Unfamiliar with Scroll-Driven Animations? Don’t worry, I’ve created a free video course “Unleash the Power of Scroll-Driven Animations” which teaches you all there is to know about Scroll-Driven Animations.
Go check it out to become an expert in Scroll-Driven Animations!
~
On social media I already shared (Twitter, Mastodon) how Paul built it:
--scroll-timeline
attached to it..card-stack
see the --scroll-timeline
, it gets hoisted using timeline-scope
on the body
. Any child of the body
– including .card-stack
– can therefore use that --scroll-timeline
.scrollsnapchange
event to let the markup know which card has snapped. This is propagated through the data-active-index
attribute on the main
element.data-active-index
attribute, different animations are attached to the cards: the snapped card gets an active animation – which rotates in 3D around the stack – and the non-snapped cards get an inactive animation – which rotates the card around its base.~
Paul’s demo is amazing but also hard to read because the Sass code uses quite some some math to generate keyframes for each card. As hinted on social media I was quite sure that the effect can also be done using shared keyframes for each card. To attach the keyframes to a single .card
a ViewTimeline on the linked .scroll-item
can be used, and using animation-range
it’s possible to limit when the animation should run.
Yesterday evening I put my money where my mouth is and took my idea for a spin. The result is not 100% perfect – there are some 3D stacking issue, most likely I need to tweak the animation-range
s a bit more – but the result comes pretty close to the original:
See the Pen
Scroll-driven animated card stack with scroll snap events (Vanilla) by Bramus (@bramus)
on CodePen.
While at it, I also reworked the scrollSnapChange
logic to use event.snapTargetInline
and by also providing a fallback using IntersectionObserver
in browsers with no support for the Snap Events.
~
🔥 Like what you see? Want to stay in the loop? Here's how:
I have a new article up on web.dev, about CSSNestedDeclarations
which is coming to all browsers.
To fix some weird quirks with CSS nesting, the CSS Working Group resolved to add the CSSNestedDeclarations interface to the CSS Nesting Specification. With this addition, declarations that come after style rules no longer shift up, among some other improvements.
These changes are available in Chrome from version 130 and are ready for testing in Firefox Nightly 132 and Safari Technology Preview 204.
Besides writing the post, I had lots of fun building this CSSRule
debugger for this blogpost. It shows you what goes on behind the scenes and how your CSS gets interpreted by the CSS Engine.
Here’s a comparison of Chrome without and with CSSNestedDeclarations
support. The version with CSSNestedDeclarations
support clearly is better.
I cannot help but stress that this is a change that is part of the CSS Nesting spec and is one that is coming to all engines. Firefox Nightly 132 is passing all tests, and with 8/11 subtests passing Safari Technology Preview 204 still has a little bit of cleaning to do before it can ship this.
]]>at-rule()
, here’s how you do it.]]>The other day on X, Adam Wathan wondered how to feature detect (Custom Property) Style Queries. While in theory you could use @supports at-rule()
for this, in practice you can’t because it has no browser support (… yet).
Drawing inspiration from my previous post on how to detect support for @starting-style
, I came up with a similar method to detect support for Style Queries: try actively using them and respond to that.
~
If you are here for just the code, here it is:
html {
--sentinel: 1;
}
@container style(--sentinel: 1) {
body {
--supported: ; /* Space Toggle */
}
}
With this you get a Space Toggle for you to use in your code.
Before you tl;dr this post, you might still want to read The problem with Safari 18 section …
~
The code works by actively trying to use a Style Query. It sets a --sentinel
property on the root element and then lets the body
element respond to it – using a Style Query – trying to declare the --supported
custom property.
When Style Queries are supported, the result will be a --supported
custom property that is set to an empty value. In browsers with no Style Queries support, the --supported
property will be the guaranteed-invalid value (of initial
). Yes, a Space Toggle.
With that --supported
Space Toggle in place, you can then use it in your CSS:
body {
--bg-if-support: var(--supported) green;
--bg-if-no-support: var(--supported, red);
background: var(--bg-if-support, var(--bg-if-no-support));
}
~
The demo below uses the code shown earlier:
See the Pen
Feature Detect Style Queries (1/2) by Bramus (@bramus)
on CodePen.
If you’re using Safari 18, you might notice it doesn’t work as expected …
~
While Safari 18 does come with support for (Custom Property) Style Queries, you might have noticed the previous demo does not work in it.
The culprit: A bug in which the root element cannot be a container in Safari 18 – https://bugs.webkit.org/show_bug.cgi?id=271040.
To work around this bug, you need to move everything down one level in your DOM tree. Like so:
body {
--sentinel: 1;
}
@container style(--sentinel: 1) {
body > * {
--supported: ; /* Space Toggle */
}
}
This means you can’t use --supported
to conditionally style the body
element itself, which might be OK for your use-case.
☝️ The bug has been fixed in Safari Technology Preview 204 and should be included in Safari 18.1 when released.
~
Here’s a demo of the code that also works in Safari. Note that it can’t be used to style the body
element itself.
See the Pen
Feature Detect Style Queries (2/2) by Bramus (@bramus)
on CodePen.
~
Feel free to repost one of the posts from social media to give them more reach, or link to this post from your own blog.
📝 Feature detect Style Queries Support in CSS.
Awaiting browser support for `at-rule()`, here’s how you do it.https://t.co/uXikYbBM2S pic.twitter.com/iuyVwlAgyX
— Bram.us (by @bramus) (@bramusblog) October 6, 2024
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Continue reading "Benchmarking the performance of CSS @property"
]]>With @property
now being Baseline Newly Available, I thought it’d be a good time benchmark the impact – if any – it has on the performance of your CSS.
When starting to use a new CSS feature it’s important to understand its impact on the performance of your websites, whether positive or negative. With
@property
now in Baseline this post explores its performance impact, and things you can do to help prevent negative impact.
For this I built and open sourced the “CSS Selector Benchmark” project which I have been working on for some time now.
To benchmark the performance of CSS we built the “CSS Selector Benchmark” test suite. It is powered by Chromium’s
PerfTestRunner
and benchmarks the performance impact of CSS. ThisPerfTestRunner
is what Blink (= Chromium’s underlying rendering engine) uses for its internal performance tests.The runner includes a
measureRunsPerSecond
method which is used for the tests. The higher the number of runs per second, the better.
The created benchmarks for @property
specifically measure how fast Blink can handle a Style Invalidation and the subsequent Recalculate Style task. This was tested with both registered and unregistered custom properties, as well as regular properties.
Read “Benchmarking the performance of CSS @property
” on web.dev →
Check out “css-selector-benchmark
” on GitHub →
transition-delay
to a CSS property under certain conditions (which you can do using a Style Query), you can persist its value.]]>By adding a long transition-delay
to a CSS property under certain conditions (which you can do using a Style Query), you can persist its value after the condition no longer applies.
~
One of the demos that I built as part of the “Solved by CSS Scroll-Driven Animations: Style an element based on the active Scroll Direction and Scroll Speed” article is a header element that hides itself on scroll.
Here’s the demo I’m talking about: as you scroll up or down, the header hides itself. When idling, it comes back into view. Check it out using a Chromium-based browser, as those – at the time of writing – are the only browsers to support Scroll-Driven Animations.
See the Pen
CSS scroll-direction detection with Scroll-Driven Animations with moving header by Bramus (@bramus)
on CodePen.
In the code of that demo there are few CSS variables that are either 0
or 1
when scrolling – or not-scrolling – when scrolling in a certain direction. The CSS looks like this:
--when-scrolling: abs(var(--scroll-direction));
--when-not-scrolling: abs(var(--when-scrolling) - 1);
--when-scrolling-up: min(abs(var(--scroll-direction) - abs(var(--scroll-direction))), 1);
--when-scrolling-down: min(var(--scroll-direction) + abs(var(--scroll-direction)), 1);
--when-scrolling-down-or-when-not-scrolling: clamp(0, var(--scroll-direction) + 1, 1);
--when-scrolling-up-or-when-not-scrolling: clamp(0, abs(var(--scroll-direction) - 1), 1);
💁♂️ If you want to know exactly how it works, go check out episode 9 of the free video course “Unleash the Power of Scroll-Driven Animations” I made, which teaches you all there is to know about Scroll-Driven Animations. The episode is also right here:
~
transition-delay
trickAs I had noted in the article, these variables are fleeting. From the moment you stop scrolling, all those variables – except --when-not-scrolling
– become 0
again. Therefore, the header in the example will reveal itself again once you stop scrolling. A better experience would be to hide the header when scrolling down and to keep it that way until the moment you scroll up again. However, I didn’t find a solution to do that back then.
Fast forward to a few months later. While at CSS Day 2024, Schepp shared that he found way to make those custom properties “sticky”. His trick? Adding a long transition-duration
to the properties when scrolling in a certain direction.
In the following snippet, the transition is stalled indefinitely when idling. That way, the --scroll-*
custom properties will retain their value until you start scrolling again.
@container style(--scroll-direction: 0) {
header {
transition-delay: calc(infinity * 1s);
}
}
~
Unfortunately I hadn’t found the time to actively use Schepp’s suggestion in the hiding header demo ever since we discussed it (but I did use it for my @starting-style
feature detection technique).
Fast forward to just last week, and Fabrizio Calderan reached out on X to share his “Hide on Scroll Down, Show on Scroll Up Header” CodePen
See the Pen
Hide on Scroll Down, Show on Scroll Up Header by Fabrizio Calderan (@fcalderan)
on CodePen.
Fabrizio came to creating the same trick Schepp had suggested to me, by relying on a long transition-behavior
which he sets in a Style Query:
@container style(--scroll-direction: 0) {
/* Scroll is idle, so we keep the current header position by setting the transition-delay to infinity */
header {
transition-delay: calc(infinity * 1s);
}
}
@container style(not (--scroll-direction: 0)) {
/* page is scrolling: if needed, the animation of the header should run immediately */
header {
transition-delay: 0s;
}
}
@container style(--scroll-direction: -1) {
/* Scrolling up, so we must reveal the header */
header {
--translate: 0;
}
}
@container style(--scroll-direction: 1) {
/* Scrolling down, so we must hide the header */
header {
--translate: -100%;
}
}
Nice one, Fabrizio!
When trying it out, you’ll notice it still is not 100% perfect though, as you can end up in situation where the header remains hidden when starting a scroll down immediately followed by a scroll up. This confirms to me that there still is a need to have the scroll-direction be exposed by the browser itself, instead of needing to rely on a hack powered by Scroll-Driven Animations. The current line of thinking is to use a Scroll-State Style Query for this.
~
Feel free to repost one of the posts from social media to give them more reach, or link to this post from your own blog.
📝 Solved by CSS Scroll-Driven Animations: hide a header when scrolling down, show it again when scrolling up
By adding a long transition-delay to a CSS property under certain conditions (with a Style Query), you can persist its valuehttps://t.co/dzFlc9Jvmt
Demo by @fcalderan pic.twitter.com/glDlpcCSIJ
— Bram.us (by @bramus) (@bramusblog) September 29, 2024
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Last week I joined my colleagues Adam and Una on The CSS Podcast. I was brought on to talk about View Transitions, a feature I’m doing the DevRel work for at Google.
In this episode Una and Adam bring on an esteemed guest Bramus, who brings us deep knowledge on View Transitions. These are easy to get started with but difficult to master, but not with Bramus here to teach us. He’ll be covering introductory to advanced API features and a big bag of examples and demos.
You can watch the episode on YouTube, which I have embedded below:
You can also listen to it using your favorite podcasting app.
]]>
Today I gave a talk at the September 2024 devs.gent meetup on how to observe and respond to Style Changes.
~
~
The talk I gave was about half an hour and covered my journey into building @bramus/style-observer
A shortcoming of
MutationObserver
is that it cannot be used to subscribe to value changes of CSS properties.While you could resort to
requestAnimationFrame
andgetComputedStyle
to plug that hole (which you shouldn’t), there is a more performant way to achieve this: leverage the power of CSS transitions in combination with the fairly recenttransition-behavior: allow-discrete
. With it, you can set up JavaScript callbacks to respond to changes in computed values of CSS properties – including Custom Properties.
This is basically a talkified version of this blog post.
~
The slides of my talk are up on slidr.io are embedded below:
These exported slides don’t contain any recordings of the demos included, but you can follow the links on the slides to check them out yourself.
~
This talk was recorded. Once the recording is released, I’ll update this post to include the embed.
~
Thanks again to Bert, Elian, and Freek for having me. Always a pleasure to speak at a local meetup and meet new and old friends. Also a big kudos to Lemon for hosting, offering food and beverages, and providing a crew to record the talks.
~
💁♂️ If you are a conference or meetup organiser, don't hesitate to contact me to come speak at your event.