A shortcoming of MutationObserver
(imho) is that it cannot be used to subscribe to value changes of CSS properties.
To plug that hole I built a library allowing just that: @bramus/style-observer
. It allows you to attach JavaScript callbacks to changes in computed values of CSS properties.
It differs from previous attempts at doing this by not relying on requestAnimationFrame
and by supporting properties that animate discretely (which includes Custom Properties). To achieve this, the library is powered by CSS Transitions and transition-behavior: allow-discrete
.
~
# Demo
Let’s jump straight in with a demo. Every time you click the document, the background-color
is set to a random color. In response to each change, thanks to @bramus/style-observer
, a callback gets executed. This callback shows the new computed value in a notification using the notyf
library.
See the Pen @bramus/style-observer demo by Bramus (@bramus) on CodePen.
~
# Installation and Usage
To obtain @bramus/style-observer
install it through NPM (or whatever package manager you are using):
npm install @bramus/style-observer
The following code shows you how to use it. See the inline comments for explanation on what each section does:
// Import the CSSStyleObserver class
import CSSStyleObserver from "@bramus/style-observer";
// Array with the names of the properties to observe.
// This can be one or more, both regular properties and custom properties
const properties = ['background-color'];
// Create a CSSStyleObserver that tracks the properties.
// Every time one of those properties their computed value changes, the passed in callback gets executed
// Here, the callback shows the new computed value in a notification
const cssStyleObserver = new CSSStyleObserver(
properties,
(values) => {
showNotification(values['background-color']);
}
);
// Have the CSSStyleObserver instance observe the `<body>` element
cssStyleObserver.attach(document.body);
// Change the background-color every time you click the document
document.documentElement.addEventListener('click', (e) => {
document.body.style.setProperty('background-color', randomBackgroundColor());
});
~
# Under the hood
Under the hood @bramus/style-observer
relies on CSS Transitions and transition-behavior: allow-discrete;
.
Each property that you monitor with @bramus/style-observer
gets a short CSS Transition applied to it. The transition is set to a very short transition-duration
of 0.001ms
and the transition-timing-function
is set to step-start
so that the transition immediately kicks in.
To catch this transition, the library also sets up a transitionstart
event listener which invokes the callback that was passed into the CSSStyleObserver
.
In CSS, transitions normally only fire for properties that can be interpolated. This does not include properties that animate discretely. Thanks to the very recent transition-behavior
, it is now possible to have transitions – along with their events – on properties that animate discretely – which includes Custom Properties – after all. This is achieved by declaring transition-behavior: allow-discrete;
onto the monitored element.
~
# Browser Support
Technically speaking, @bramus/style-observer
works in any browser that supports CSS Transitions. To also observe properties that animate discretely, support for transition-behavior: allow-discrete;
is also required so in practice that boils down the the following browsers that are supported:
- Chrome/Edge 117
- Firefox 129
- Safari 18
Note that all the browsers have bugs when transitioning Custom Properties. See the next section for details.
~
# A note on transitioning Custom Properties
There are a bunch of browser bugs – in all browsers – when it comes to transitioning custom properties.
Chrome for example is currently affected by https://crbug.com/360159391 in which it does not trigger transition events for unregistered custom properties. You can work around this Chrome bug by registering the custom property using @property
.
Safari doesn’t like the Chrome workaround for certain syntax
es as it then seems to be stuck in a transition loop. This happens when the custom property is not registered or when the custom property is a string ("<string>"
, "*"
/…). Other syntaxes – such as "<number>"
and "<custom-ident>"
– don’t mess up things in Safari (and also bypass that Chrome bug).
And Firefox finally doesn’t like it when a registered custom property uses a syntax
with a type that can be interpolated.
UPDATE 2024.09.02 – I have gathered all issues on a dedicated site at https://allow-discrete-bugs.netlify.app/.
Right now, the only cross-browser way to observe Custom Properties with @bramus/style-observer
is to register the property with a syntax
of "<custom-ident>"
.
Note that <custom-ident>
values can not start with a number, so you can’t use this type to store numeric values.
~
# Prior Art and Acknowledgements
This section is purely informational.
The requestAnimationFrame
days
Wanting a Style Observer is not a new idea. There have been attempts at making this before such as ComputedStyleObserver
by keithclark (2018) and StyleObserver
by PixelsCommander (2019).
Both rely on using requestAnimationFrame
, which is not feasible. This because requestAnimationFrame
callbacks get executed at every frame and put a load on the Main Thread.
Furthermore, the callback used in those libraries would also typically trigger a getComputedStyle
and then loop over all properties to see which values had changed, which is a slow process.
Besides putting this extra load on main thread, looping over the getComputedStyle
results would not include Custom Properties in Chrome due to https://crbug.com/41451306.
And finally, having a requestAnimationFrame
forces all animations that run on the Compositor to also re-run on the Main Thread. This because getComputedStyle
needs to be able to get the up-to-date value.
Add all those things up, and it becomes clear that requestAnimationFrame
is not a feasible solution 🙁
The CSS transitions approach
In 2020, Artem Godin created css-variable-observer
which ditched the requestAnimationFrame
approach in favor of the CSS Transitions approach. While that library is more performant than the previous attempts it has the big limitation that it only works with (custom) properties that contain <number>
values.
This is due to the (clever!) approach to storing all the data into the font-variation-settings
property.
The choice for font-variation-settings
was made because its syntax is [ <opentype-tag> <number> ]#
(with <opentype-tag>
being equal to <string>
) and it’s a property that is animatable.
🙏 The code for @bramus/style-observer
started out as a fork of css-variable-observer
. Thanks for your prior work on this, Artem!
Transitioning discretely animatable properties
Just two days ago, former colleague Jake Archibald shared a StyleObserver experiment of his in the CSS Working Group Issue discussing this. His approach relies on Style Queries and a ResizeObserver
to make things work.
In the follow-up discussion, Jake foolishly wrote this:
Huh, could some of the discrete value animation stuff be used to make this work for non-numbers?
Whoa, that was exactly the clue that I needed to go out and experiment, resulting in this Proof of Concept. That POC was then used to build @bramus/style-observer
into what it is now 🙂
🙏 Thanks for providing me with the missing piece of the puzzle there, Jake!
~
# Spread the word
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.
Introducing @bramus/style-observer, a MutationObserver for CSS.
It allows you to attach JavaScript callbacks to changes in computed values of CSS properties.
🔗 https://t.co/6XlDe7Ixd0 pic.twitter.com/3lL2rnfiRg
— Bram.us (by @bramus) (@bramusblog) August 30, 2024
~
🔥 Like what you see? Want to stay in the loop? Here's how:
This is excellent! Definitely something I’ve wanted a solid solution for for a long time, exciting to see one!
I attempted it in 2019 without using getComputedStyle or requestAnimationFrame
https://www.npmjs.com/package/css-var-listener
Needed it for an augmented-ui experiment before I came up with the Space Toggle to solve my specific use case more easily. 🙂