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:
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:
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:
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.
~
Ahmad recently launched a redesign of his website. On it he features some nice Scroll-Driven Animations to enhance the experience. As one should do, the CSS for it is gated behind an @supports
result which feature detects availability for Scroll-Driven Animations.
@supports (animation-timeline: scroll()) {
/* Scroll-Driven Animations related styles go here */
}
However, even though he gated the functionality behind a feature check, he got some reports from users that it wasn’t working as expected in Firefox: the animations would run on scroll but the timing would be waaaayy off. So he reached out to me, asking what gives.
The problem is that, at the time of writing, Firefox Nightly – which has Scroll-Driven Animations enabled – only has a partial implementation of it. They support a bit of it, but not everything. One of the things they do support is animation-timeline: scroll()
, making Firefox Nightly pass the feature detection snippet from above.
~
To filter out Firefox Nightly, you need to extend the feature detection for Scroll-Driven Animations to include a check for something they don’t support yet. For this, you can check for animation-range
support, as that’s a property that is not part of their partial implementation.
Like so:
@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) {
/* Scroll-Driven Animations related styles go here */
/* This check excludes Firefox Nightly which only has a partial implementation at the moment of posting (mid-September 2024). */
}
Here’s a live demo:
See the Pen
Feature detect scroll-driven animations support but exclude Firefox’s partial implementation by Bramus (@bramus)
on CodePen.
That’s it 🙂
~
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 detecting Scroll-Driven Animations with @ supports in CSS: you want to check for `animation-range` too, in order to exclude Firefox Nightly, which only has a partial SDA implementation.
Details: https://t.co/K0OMGj5olj pic.twitter.com/HIcRBV5yzM
— Bram.us (by @bramus) (@bramusblog) September 24, 2024
~
🔥 Like what you see? Want to stay in the loop? Here's how:
clip-path
, border-radius
, opacity
, … properties for you by default? And what if it preserved the hierarchy of the tree?]]>The current state of View Transitions is nice, but it could be even better …
~
clip-path
problemAn area where View Transitions fall short is how it handles clip-path
, border-radius
, opacity
, …
Take this demo, which has a view-transition-name
on the grey circle and a view-transition-name
on the text contained within that circle.
As you can see in the recording, the snapshots cross-fade whereas you’d most likely want the clip-path
of the circle to actually animate instead.
Also, the nested text bleeds out of its container; this because the snapshots are a flat list instead of a nested tree:
::view-transition
├─ ::view-transition-group(card)
| └─ ::view-transition-image-pair(card)
| ├─ ::view-transition-old(card)
| └─ ::view-transition-new(card)
└─ ::view-transition-group(text)
└─ ::view-transition-image-pair(text)
├─ ::view-transition-old(text)
└─ ::view-transition-new(text)
While this outcome of these View Transition limitations could be acceptable in some cases, here it is not.
~
But what if this issues could be solved? So that you’d get the following instead:
– The clip-path
on the circle actually transitions
– The text stays clipped by the circle
Like so:
That’s much better, right?
To achieve this, there are 2 changes needed for View Transitions:
clip-path
, border-radius
, opacity
, …) individually and copies them onto the ::view-transition-group()
.Combine the two, and you are able to get what you see in that second recording there 🙂
~
At the time of writing, Chrome is actively working on implementing these two new features, gated behind a feature flag. To try these out, you need Chrome Canary with Experimental Web Platform Features turned on.
The new view-transition-group
property – part of the View Transitions L2 specification – allows you to determine where to place the snapshot. In the demo shown before, I have the text
’s pseudos be nested inside the box
group pseudo, like so:
.card {
view-transition-name: card;
}
.card a {
view-transition-name: text;
view-transition-group: card; /* 👈 Make the pseudo-tree for this “text” snapshot to be nested inside the “card” pseudo-tree */
}
::view-transition
└─ ::view-transition-group(card)
├─ ::view-transition-image-pair(card)
| ├─ ::view-transition-old(card)
| └─ ::view-transition-new(card)
└─ ::view-transition-group(text)
└─ ::view-transition-image-pair(text)
├─ ::view-transition-old(text)
└─ ::view-transition-new(text)
You can also use a value of nearest
to make it less explicit
As for the capturing, Chrome is experimenting with a new capture mode that animates a select set of properties directly onto the ::view-transition-group()
.
The set of properties we are looking at are clip-path
, border-radius
, opacity
, mask
, filter
, and clip
properties.
You can already try this out in Chrome Canary but note that the implementation still needs some work as the border-radius
and clip
properties aren’t implemented yet.
If all goes well, this new mode will eventually replace the old mode.
Pretty sweet, right?
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Last week I scratched my own itch (again) and built a (offline!) command line tool for “CanIUse …” and MDN’s browser-compat-data
.
~
You can install @bramus/caniuse-cli
through NPM (or whatever package manager you are using).
npm i -g @bramus/caniuse-cli
Once installed, you can call caniuse
on the command line. The passed in argument is your search term.
caniuse viewport-units
The package differs from other attempts at this by not only using caniuse-db
but by also integrating @mdn/browser-compat-data
. That means you can also query for things like the upcoming calc-size()
or @property
.
caniuse calc-size
caniuse @property
Because both datasets are stored locally, @bramus/caniuse-cli
requires no live internet connection once installed.
Furthermore it also collapses the releases in the output table, just like the “CanIUse …” website does it.
~
The source code is available on GitHub. PRs very welcome as this was thrown together in a jiffy, hacking on the original by @dsenkus.
One area I am specifically looking help for, is a way to provide an auto-update mechanism for the datasets. I think I have a solution for this, but I’m sure it could be done better – especially because my approach is limited to Node’s npm
only.
~
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.
I scratched my own itch (again) and built a @caniuse CLI tool.
It differs from other attempts at this by also integrating @MozDevNet’s browser-compat-data + collapsing releases in the table (just like the website does).
⌨️npm i -g @bramus/caniuse-cli
🔗 https://t.co/Ocp3t9erEz pic.twitter.com/Ly6SPNkgKo— Bramus (@bramus) September 9, 2024
~
🔥 Like what you see? Want to stay in the loop? Here's how: