Houdini &
Polyfilling CSS

Philip Walton / philipwalton.com / @philwalton

CSS Houdini on Smashing Magazine

What's so hard about polyfilling CSS?

It's easier to show than to tell...

A new CSS function

random() — a number between 0 and 1

.progress-bar { width: calc(**random()** * 100%); }

How do you tell the browser about a CSS function it doesn't already understand?

In JavaScript you'd do something like this:

if (typeof Math.random != 'function') { Math.random = function() { // Implement polyfill here... }; }

but you can't write imperative code in CSS.

How CSS polyfills work

Fundamentally, all CSS polyfills do the same thing.

They turn code the browser doesn't understand
into code the browser does understand.

calc(**random()** * 100%);
calc(**0.35746** * 100%);

Okay, so you have to update the CSS,
but where do you do that?

The CSS Object Model

  • document.styleSheets
  • CSSStyleSheetList
  • CSSStyleSheet
  • CSSRuleList
  • CSSStyleRule
  • CSSStyleDeclaration
for (const stylesheet of document.styleSheets) { // Flatten nested rules (@media blocks, etc.) into a single array. const rules = [...stylesheet.rules].reduce((prev, next) => { return prev.concat(next.cssRules ? [...next.cssRules] : [next]); }, []); for (const rule of rules) { for (const property of Object.keys(rule.style)) { const value = rule.style[property]; if (value.includes('random()')) { rule.style[property] = **value.replace('random()', Math.random());** } } } }

If the original CSS isn't in the CSSOM, where is it?

Getting all CSS style rules

export const getPageStyles = () => { // Query the document for any element that could have styles. var styleElements = [...**document.querySelectorAll('style, link[rel="stylesheet"]')**]; // Fetch all styles and ensure the results are in document order. // Resolve with a single string of CSS text. return Promise.all(styleElements.map((el) => { if (el.href) { return **fetch(el.href).then((response) => response.text());** } else { return **el.innerHTML;** } })).then((stylesArray) => stylesArray.join('\n')); }

Now that you have the raw CSS text, you have to parse it.

Okay, can the browser
help me parse the CSS?

#lols

Parsing the raw CSS text

There are many open source parsers that will convert
a string of CSS into an abstract syntax tree (AST).

For this polyfill we'll use PostCSS.

The PostCSS AST

.progress-bar { width: calc(random() * 100%); }
{ "type": "root", "nodes": [ { "type": "rule", "selector": ".progress-bar", "nodes": [ { "type": "decl", "prop": "width", "value": "calc(random() * 100%)" } ] } ] }

Inspecting the parsed CSS

import postcss from './third-party/postcss.js'; import {getPageStyles} from './get-page-styles.js'; getPageStyles() .then((css) => **postcss.parse(css)**) .then((ast) => console.log(ast));

Checking In

Up to this point, we've written a lot of code…

…but none of it has anything to do with the
core functionality of our polyfill.

Next steps

Modifying the AST

export const randomFunctionPlugin = postcss.plugin('random-function', () => { return (css) => { css.walkRules((rule) => { rule.walkDecls((decl, i) => { if (decl.value.includes('random()')) { decl.value = **decl.value.replace('random()', Math.random());** } }); }); }; });

Replacing the page styles

export const replacePageStyles = (css) => { // Get a reference to all existing style elements. const styles = document.querySelectorAll('style, link[rel="stylesheet"]'); // Create a new <style> tag with all the polyfilled styles. const polyfillStyles = document.createElement('style'); polyfillStyles.innerHTML = css; **document.head.appendChild(polyfillStyles);** // Remove the old styles once the new styles have been added. styles.forEach((el) => el.parentElement.removeChild(el)); };

Putting the polyfill together

import postcss from './third-party/postcss.js'; import {getPageStyles} from './get-page-styles.js'; import {randomFunctionPlugin} from './random-function-plugin.js'; import {replacePageStyles} from './replace-page-styles.js'; getPageStyles() .then((css) => postcss([**randomFunctionPlugin**]).process(css)) .then((result) => replacePageStyles(result.css));

How can we update the polyfill to target individual elements?

Option #1

Add inline styles to every element matching the rule selector.

// ... rule.walkDecls((decl, i) => { if (decl.value.includes('random()')) { const elements = **document.querySelectorAll(rule.selector);** for (const element of elements) { **element.style[decl.prop]** = decl.value.replace('random()', Math.random()); } } }); // ...

Option #2

Check the rest of the CSS for matching rules, and then only replace the random() function with a random number as an inline style if it's the last matching rule. Wait, that won't work, because we have to account for specificity, so we'll have to manually parse each selector to calculate its specificity. Then we can sort the matching rules in specificity order from low to high, and only apply the declarations from the most specific selector. Oh and then there's @media rules, so we'll have to manually check for matches there as well. And speaking of at-rules, there's also @supports—can't forget about that. And lastly we'll have to account for property inheritance, so for each element we'll have to traverse up the DOM tree and inspect all its ancestors to get the full set of computed properties. Oh, sorry, one more thing: we'll also have to account for !important, which is calculated per-declaration instead of per-rule, so we'll have to maintain a separate mapping for that to figure out which declaration will ultimately win.

Hold up, didn't you just describe
the cascade?

Option #3

Rewrite all selectors that contain the random() function in such a way that instead of one selector matching many elements, you have many selectors each only matching one element.

* { box-sizing: border-box; } **p {** opacity: random(); } .foo { opacity: initial; }
* { box-sizing: border-box; } p**[data-pid="1"]** { opacity: .23421; } p**[data-pid="2"]** { opacity: .82305; } p**[data-pid="3"]** { opacity: .31178; } .foo { opacity: initial; } *​**:not(.z)** { box-sizing: border-box; } p**[data-pid="1"]** { opacity: .23421; } p**[data-pid="2"]** { opacity: .82305; } p**[data-pid="3"]** { opacity: .31178; } .foo**:not(.z)** { opacity: initial; }

Adding a unique ID to each element…

css.walkRules((rule) => { const newRules = {}; rule.walkDecls((decl, i) => { if (decl.value.match('random()')) { for (const el of **document.querySelectorAll(rule.selector)**) { const **pid** = el.dataset.pid || (el.dataset.pid = getUniqueId()); const **newRule** = newRules[pid] || (newRules[pid] = rule.clone({ selector: appendToSelectors(rule.selector, **`[data-pid="${pid}"]`**), nodes: [], })); newRule.nodes.push(decl.clone({ value: **decl.value.replace('random()', Math.random())**, })); } } // ...

"Up-specifizing" the other rules…

// ... // Clone the current rule and update the selector. rule.parent.insertBefore(rule, rule.clone({ selector: appendToSelectors(rule.selector, **':not(.z)'**) })) // Insert all the new rules before the current rule. for (const id of Object.keys(newRules)) { rule.parent.insertBefore(rule, newRules[id]); } // Remove the current rule and continue iterating. rule.remove(); }); });

The final plugin code

import postcss from './third-party/postcss.js'; import {randomFunctionPlugin} from './random-function-plugin'; import {getPageStyles} from './get-page-styles'; import {replacePageStyles} from './replace-page-styles'; getPageStyles() .then((css) => postcss([randomFunctionPlugin]).process(css)) .then((result) => replacePageStyles(result.css));

Zomg! You've solved everything!

Unresolved issues

Unavoidable problems

Understanding the performance implications

What is the solution?

Houdini

A set of low-level APIs that give developers hooks
into the browsers styling and layout mechanisms.

The rendering pipeline with Houdini APIs

New features

Examples

Paint and layout functions

#foo { background-image: **paint(circle)**; } #bar { display: **layout(masonry)**; }
<script> CSS.paintWorklet.addModule('./circle.js'); CSS.layoutWorklet.addModule('./masonry.js'); </script>
registerPaint(**'circle'**, class { static get inputProperties() { return ['--circle-color']; } **paint**(ctx, geom, props) { const x = geom.width / 2; const y = geom.height / 2; const radius = Math.min(x, y); const color = props.get('--circle-color').toString(); ctx.fillStyle = color; ctx.arc(x, y, radius, 0, 2 * Math.PI, false); ctx.fill(); } });

Cool story bro, but I could make a
circle in CSS without Houdini...

Let's add some additional properties...

registerPaint('circle', class { static get inputProperties() { return ['--circle-color', **'--circle-x', '--circle-y', '--circle-radius'**]; } paint(ctx, geom, props) { const x = parseFloat(**props.get('--circle-x')**); const y = parseFloat(**props.get('--circle-y')**); const radius = parseFloat(**props.get('--circle-radius')**); const color = props.get('--circle-color').toString(); ctx.fillStyle = color; ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fill(); } });

...and transition those properties when a class is added...

button { background-color: #37e; --circle-radius: 0; --circle-color: #fff8; } button.animating { background-image: paint(circle); transition: **--circle-radius 1s cubic-bezier(0, 0, 0.4, 1)**, **--circle-color 1s linear**; --circle-radius: 600; --circle-color: #fff0; }

...and add that class when the button is clicked.

for (const button of document.querySelectorAll('button')) { button.addEventListener('click', (evt) => { **button.classList.add('animating');** button.styleMap.set('--circle-x', CSS.px(evt.offsetX)); button.styleMap.set('--circle-y', CSS.px(evt.offsetY)); }); button.addEventListener('transitionend', () => { button.classList.remove('animating'); }); }

This works because of the new properties and values API

CSS.registerProperty({ name: '--circle-radius', syntax: '<number>', initialValue: '0', inherits: true, // defaults to false });

Ok, but how does this stuff
relate to polyfilling CSS?

You can use these functions to polyfill standard features

.media { background-image: **conic-gradient**(red, white, black); }

.media { background-image: **paint(conic-gradient, 0 50% 100%, red white black)**; }

registerPaint('conic-gradient', class { static get inputArguments() { return ['<angle>+ | <percentage>+', '<color>+']; } paint(ctx, geom, props, args) { // Draw gradient from args... } });

But I don't want to write paint functions for standard features!

A PostCSS plugin

#foo { background: conic-gradient(...); }
#foo { **background: paint(conic-gradient,...);** background: conic-gradient(...); }

A webpack loader

module: { rules: [ { test: /\.css$/, use: [ 'sass-loader', **'houdini-loader',** // ... } } ] }

What about features that aren't
paint or layout related?

Custom functions

.progress-bar { width: calc(**random()** * 100%); }
button:hover { background-color: **darken(var(--btn-color), 10%)**; }

Custom pseudo-classes

.next-sibling**:preceded-by(.previous-sibling)** { margin-top: 1em; }

Custom @-rules

.component { /* Default, small size styles... */ **@condition** (var(--container-width)) >= 48em) { /* Large size styles... */ } }

This + ResizeObserver gives you container queries

const ro = new **ResizeObserver**((entries) => { for (const entry of entries) { const {width} = entry.contentRect; const element = entry.target; element.styleMap.set('--container-width', CSS.px(width)); } }); document.querySelectorAll('.component') .map((el) => el.parentNode) .forEach(**ro.observe**);

Wrapping Up

Polyfilling CSS today is much
harder than it should be

Without the ability to polyfill new features, innovation
will move at the pace of the slowest-adopting browser.

Houdini makes CSS truly extensible

It transfers the ability to innovate
from spec authors to web developers.

It will enable proper CSS polyfills,
but it will also do so much more.

Final Thought

Developers complain about too much innovation in the JavaScript community. But you don't hear that about CSS.

#makecssfatigueathing

Fin

Twitter
@philwalton
Website
philipwalton.com
Github
github.com/philipwalton
Slides
github.com/philipwalton/talks