Introducing @bramus/style-observer, a MutationObserver for CSS

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

~

Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Join the Conversation

1 Comment

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.