Jonathan Neal

Observing CSS

Did you know you can observe CSS properties in JS?

observeStyle(someElement, '--someProperty',
	// called whenever `--someProperty` value changes
	(value) => {
		console.log({ value })
	}
)

I think this is a powerful pattern, like style queries for JS.

We can use this functionality to create experiences where our users define the behavior of custom interfaces within their own CSS.


Want to read the source? Jump to the source code.

Observing CSS for Custom Navigation

Imagine we are building a custom navigation element, and we want to allow authors to define when the ‘hamburger’ experience kicks in.

<a-nav>
	<a href="/getting-started/">Getting Started</a>
	<a href="/components/">Components</a>
	<a href="/recipes/">Recipes</a>
</a-nav>
<style>
a-nav {
	--affordance: default;

	@media (width < 640px) {
		--affordance: disclosure;
	}
}
</style>

In this example, a ‘disclosure affordance’ is applied to the element at screen sizes of 640px or less.

To accomplish this within our <a-nav> constructor, we can use observeStyle to watch the --disclosure property for changes.

class ANavElement extends HTMLElement {
	constructor() {
		super()

		this.attachShadow({ mode: 'open' })

		this.shadowRoot.innerHTML = [
			`<slot name="button">`,
				`<button aria-label="Open menu">≡</button>`,
			`</slot>`
		].join('')

		const [ slot ] = this.shadowRoot.children

		observeStyle(this, '--affordance',
			(value) => {
				slot.hidden = value !== 'disclosure'
			}
		)
	}
}

In this simplified example, we show or hide the disclosure button depending on whether --affordance has a value of disclosure.

Now, let’s review how the actual observer is coded.

Source Code

Here is the source for the observeStyle function.

Scroll ahead to read how we create it.

const observeStyle = (
	target,
	property,
	callback,
	initialValue = ''
) => {
	let frameId, value

	const css = getComputedStyle(target)

	const observer = () => {
		frameId = requestAnimationFrame(observer)

		value = css.getPropertyValue(property).trim()

		if (value !== initialValue) {
			callback(initialValue = value)
		}
	}

	observer()

	return () => cancelAnimationFrame(frameId)
}

How It Works

This observeStyle function requires 3 arguments, and it allows an optional 4th argument.

  1. Our DOM element we want to observe,
  2. Our CSS property we want to observe on that element, and,
  3. Our callback we want to run whenever that CSS property value changes.
  4. Optionally, the initial value of our CSS property. This value is otherwise an empty string.
const observeStyle = (
	target,
	property,
	callback,
	initialValue = ''
) => {}

Next, observeStyle uses getComputedStyle to retreive an object of all the computed CSS properties applied to our element. This is a live collection whose values are always up to date.

const css = getComputedStyle(target)

Next, the observer function uses requestAnimationFrame to queue another run before the next frame. This function is called repeatedly before each repaint.

let observer = () => {
	frameId = requestAnimationFrame(observer)
}

Within the observer function, getPropertyValue returns the value of our css property. This is trimmed to normalize custom property values, which otherwise include, for instance, leading spaces.

value = css.getPropertyValue(property).trim()

Finally, if our observed value is changed, then our callback is run with the updated value.

if (value !== initialValue) {
	callback(initialValue = value)
}

React Version

Want to use this in React?

Here is the equivelent source code

import { useCallback, useLayoutEffect, useState } from 'react'

export function useCSSProperty<T extends Element>(
	property: string,
	initialValue = ''
) {
	const [ element, setElement ] = useState<T | null>(null)
	const [ value, setValue ] = useState(initialValue)

	const ref = useCallback((element: T) => {
		if (element !== null) {
			setElement(element)
		}
	}, [])

	useLayoutEffect(() => {
		if (element === null) return

		const css = getComputedStyle(element)

		let frameId: number
		let observer = () => {
			frameId = requestAnimationFrame(observer)

			const newValue = css.getPropertyValue(property).trim()

			if (value !== newValue) {
				setValue(newValue)
			}
		}

		observer()

		return () => cancelAnimationFrame(frameId)
	}, [ element, property ])

	return [ ref, value ]
}

Finally, please consider all code shared in this article public domain, unless otherwise specified.

Affordances