CSS Houdini:
From CSS Custom Properties to JavaScript Worklets and back

About me

CSS Custom Properties

Preprocessors

Variables and Operators (+, -, *, /, %)

$font-size: 10px;
$font-family: Helvetica, sans-serif;

body {
  font: $font-size $font-family;
}

.mark{
  font-size: 1.5 * $font-size;
}

Preprocessor variable problems

  • Each preprocessor has own syntax
  • Additional setup
  • Recompilation after changes is required
  • Compilation takes time
  • Absence of interaction with the browser
  • Not aware of the DOM structure

Native CSS Syntax

/* declaration */
--VAR_NAME: <declaration-value>;
/* usage */
var(--VAR_NAME)
/* root element selector (global scope) */
/* usually <html> */
:root {/* make available for whole the app */
  /* CSS variables declarations */
  --main-color: #ff00ff;
  --main-bg: rgb(200, 255, 255);
}

body {
  /* variable usage */
  color: var(--main-color);
}

"--" prefix picked to prevent preprocessors to compile Custom Properties

Declaration and usage

Supported in all the major browsers

Valid examples

:root{
  --main-color: #4d4e53;
  --main-bg: rgb(255, 255, 255);
  --logo-border-color: rebeccapurple;

  --header-height: 68px;
  --content-padding: 10px 20px;

  --base-line-height: 1.428571429;
  --transition-duration: .35s;
  --external-link: "external link";
  --margin-top: calc(2vh + 20px);

  /* Valid CSS custom properties */
  /* can be reused later in, say, JavaScript. */
  --foo: if(x > 5) this.width = 10;
}

Declaration and use cases

/* Default values */
.box{
  /*--- Default values ---*/
  /* 10px is used */
  margin: var(--possibly-non-existent-value, 10px);

  /* The --main-padding variable is used */
  /* if --box-padding is not defined. */
  padding: var(--box-padding, var(--main-padding));
}
/* Reuse values in other vars */

.box__highlight::after{
  --box-text: 'This is my box';

  /* Equal to --box-highlight-text:'This is my box with highlight'; */
  --box-highlight-text: var(--box-text)' with highlight';

  content: var(--box-highlight-text);
}

Declaration and use cases

CSS-Wide Keywords and "all" Property

.common-values{
  /* applies the value of the element’s parent. */
  --border: inherit;

  /* applies the initial value as defined in the CSS specification
  (an EMPTY value, or NOTHING in some cases of CSS custom properties). */
  --bgcolor: initial;

  /* applies the INHERITED value if a property is normally inherited
  (as in the case of CSS defined Custom Properties) or the initial value
  if the property is normally not inherited. */
  --padding: unset;

  /* resets the property to the default value (user agent’s)
  (an EMPTY value in the case of CSS custom properties). */
  --animation: revert;


  all: initial; /* resets all* CSS properties, EXCEPT CUSTOM PROPERTIES */

  /* Future? */
  --: initial; /* resets all CSS Custom Properties */
}
/* Separate values in CSS Custom Properties */

.transform {
  --scale: scale(2);
  --rotate: rotate(10deg);

  transform: var(--scale) var(--rotate);
}

.transform:hover{
  --rotate: rotate(90deg);
}
.transform {
  transform: scale(2) rotate(10deg);
}

.transform:hover{
  transform: scale(2) rotate(90deg);
}

Emulating non-existing CSS rule

Operations: +, -, *, /

:root {
  --block-font-size: 1rem;
}

.block__highlight {
  /* DOESN'T WORK */
  font-size: var(--block-font-size)*1.5;
}

CSS   calc(🤘)   to the rescue (for values)

:root {
  --block-font-size: 1rem;
}

.block__highlight {
  /* WORKS */
  font-size: calc(var(--block-font-size)*1.5);
}

All together: calc() and change values separately

Calculate all the app colors from the base-color (theming)

Changes to Custom Props have immediate effect

// SCSS
.box {
  $indent: 30px;

  margin: $indent; /* 30px */

/* is ignored as changed
   after value is applied */
  $indent: 50px;
}

.box:hover{
  /* is ignored as no
  assignement is provided after this */
  $indent: 80px;

  /* margin: $indent; to apply */
}
// CSS
.box {
  --indent: 30px;

  margin: var(--indent); /* 50px */

/* is applied as native
   variables are alive
   as other CSS props */
  --indent: 50px;
}

.box:hover{
    /* is applied, so
   margin: 80px; on hover */
    --indent: 80px;
}

CSS Properties are aware ​of the DOM structure

// SCSS
.text {
  $text-size: 20px;
  font-size: $text-size;
}

.active {
  $text-size: 30px;
}
/* CSS */
.text {
  --text-size: 20px;
  font-size: var(--text-size);
}

.active {
  --text-size: 30px;
}
<!-- HTML -->
<div class="text">.text</div>

<div class="text active">.text.active</div>

Using Custom Properties With JavaScript

  • pass breakpoints data from CSS
  • read values...
const breakpointsData = 
  document.querySelector('.breakpoints-data');

// GET
const phone = getComputedStyle(breakpointsData)
    .getPropertyValue('--phone');

// SET
breakpointsData.style
    .setProperty('--phone', 'custom');
.breakpoints-data {
  --phone: 480px;
  --tablet: 800px;
}
  • assign CSS value calculated in JS
  • update UI depending on the application state...

Rotate page elements using CSS variables

Check if supported

/* CSS */
@supports (--css: variables){
  /* supported */
}

@supports (not(--css: variables)){
  /* not supported */
}
// JavaScript
const isSupported = 
    CSS.supports('--css', 'variables');

if (isSupported) {
  /* supported */
} else {
  /* not supported */
}
<!-- HTML (in case of older browsers support)  -->
<link href="without-css-custom-properties.css"
    rel="stylesheet" type="text/css" media="all" />

<script>
if(isSupported){
  removeCss('without-css-custom-properties.css');
  loadCss('css-custom-properties.css');
  // + apply some application enhancements
  // using the custom properties
}
</script>

Current constraints

Custom CSS Properties  by default are:

  • not typed
  • in result - not animatable

Why animation is important

Why typed CSS properties and values are important

  • support and validation in IDE
  • syntax highlight
  • performance
  • Browser support
  • DevTools support
  • linters, compilers...

Introducing CSS property types?

Property Value definition field Example value
text-align left | right | center | justify center
padding-top
 
<length> | <percentage> 5%
border-width [ <length> | thick | medium | thin ]{1,4} 2px medium 4px

Houdini group

CSS
Properties
& Values
API

CSS
Typed
OM

CSS
Parser
API

Font
Metrics
API

Worklets

CSS
Layout
API

CSS
Paint
API

CSS
Animation
Worklet
API

CSS
Properties
& Values
API

CSS
Typed
OM

// CSS -> JS
const map = document.querySelector('.example').styleMap;
console.log( map.get('font-size') );
// CSSUnitValue {value: 5, unit: "px", type: "length"}


// JS -> JS
console.log( new CSSUnitValue(5, "px") );
// CSSUnitValue {value: 5, unit: "px", type: "length"}


// JS -> CSS
// set style "transform: translate(50px, 80%);"
elem.styleMap.set('transform',
    new CSSTransformValue([
        new CSSTranslation(
          new CSSUnitValue(50, 'px'), new CSSUnitValue(80, '%')
        )]));

behind the “Experimental Web Platform features” flag in

CSS.registerProperty({
  name: "--stop-color",
  syntax: "<color>",
  inherits: false,
  initialValue: "black"
});
@property --stop-color {
  syntax: "<color>",
  inherits: false,
  initialValue: black
}

Why it's important

Without

With

"syntax" of CSS properties

Default: "*"

Supported Values:
"<length>"
"<number>"
"<percentage>"
"<length-percentage>"
"<color>"
"<image>"
"<url>"
"<integer>"
"<angle>"
"<time>"
"<resolution>"
"<transform-function>"

Examples:

"<length> | <percentage>"

both, but not calc() combinations

 

"<length-percentage>"

both + calc() combinations of both types

 

"big | bigger"

accepts either value

 

"<length>+"

accepts a list of length values

And...

CSS Custom Properties

should be animatable

since they are provided with types?

Let's try!

Native CSS Animation

element.animate([
  {cssProperty: 'fromValue'},
  {cssProperty: 'toValue'}
], {
    duration: timeInMs,
    fill: 'none|forwards|backwards|both',
    delay: delayInMs,
    easing: 'linear|easy-in|cubic-bezier()...',
    iterations: iterationCount|Infinity
});
rabbit.animate(
  [
    { transform: "translateX(0)" },
    { transform: "translateX(115px)" }
  ],
 
 {
    duration: 1000, // ms
    fill: "forwards", // stay at the end
    easing: "easy-in-out"
  }
);
@keyframes rabbitMove {
  0% {
    transform: translateX(0);
  }
  100% {
    transform: translateX(115px);
  }
}
.rabbit {
  animation: rabbitMove 1s ease-in-out;
  animation-fill-mode: forwards;
}

Polyfill is available 🎉

Native JavaScript animation

CSS/JS/Browser

CSS

JavaScript

Browser

CSS Custom Properties

CSS Property Types

Typed OM API

CSSOM

?

Browser rendering

image credits: 1, 2

Pixel

rendering
pipeline

Internal browser engine

Houdini’s goal is to expose browser APIs to allow web developers to hook up their own code into the CSS engine.

It’s probably not unrealistic to assume that some of these code fragments will have to be run every. single. frame.

- similar to Web and Service Workers

- have a separate thread

- don't interact with DOM directly

- limited API -> very performant

- use the JS additions, essentially- ECMAScript 2015+ Classes with named methods

- triggered when needed and possible

The paint stage of CSS is responsible for painting the background, content and highlight of a box (based on the box’s size and computed style). The API allows to paint a part of a box in response to size / computed style changes with an additional <image> function.

- background-image
- border-image
- list-style-image
- content

- cursor

Custom image can be paint on every browser paint action.
Applicable for:

CSS
Paint
API

Paint Worklet example

/* CSS */
.multi-border {--border-top-width: 10; border-image: paint(border-colors);}

// JS
CSS.registerProperty({
    name: '--border-top-width',
    syntax: '<number>',
    inherits: false,
    initialValue: 0,
});
// add a Worklet
CSS.paintWorklet.addModule('border-colors.js');

// WORKLET "border-colors.js"
registerPaint('border-colors', class BorderColors {
    static get inputProperties() { return ['--border-top-width']; }

    paint(ctx, size, styleMap) {
        const elWidth = size.width;
        const topWidth = styleMap.get('--border-top-width').toString();
        // draw a border
        ctx.fillStyle = 'magenta';
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(elWidth, 0); ctx.lineTo(elWidth, topWidth);
        ctx.lineTo(0, topWidth); ctx.lineTo(0, 0);
        ctx.fill();
    }
});

Browsers are smart and trigger paint only when and where needed (demo)

Activate "Rendering pane" -> "Paint Flashing" to highlight paints

- to create own layout algorithms
- having access to set constraints, behaviour, boundaries

- interact with blocks, fragments and even texts

/* CSS */
.center {
  display: layout(centering);
}

CSS
Layout
API

/* CSS */
.photos {
  display: layout(masonry);
}
// JS (layout worklet)
registerLayout('masonry',
 class {
  *layout(space, children,
    styleMap, edges) {/*...*/}
});

Sticky Header implemented in the stable version of Chrome using Houdini task force achievements

Web Animation API based, makes possible to run in own dedicated thread isolated from the main for performance
("best-effort" basis- runs on every frame, up to the frame deadline)

Mostly scroll-linked animations and effects:

- sticky elements

- smooth scroll animations

- scroll snapping

- scroll up bar

Use cases

Worklet and Web Animation API polyfills are available 🎉

CSS
Animation
Worklet
API

Compositor

Accelerated (e.g. compositor only) properties

Pixel

rendering

pipeline

Changing does not trigger any geometry changes or painting

Carried out by the compositor thread with the help of the GPU.

Property changes which can be handled by the compositor alone (not changing scroll offset / layout)

Promote with "will-change"

opacity

transform

Scroll Position Effects Demo- CSS/HTML/JS

<!-- HTML (scripts) -->
<!-- Polyfill checks and loads (if needed)
    both Web Animation API and Animation Worklet polyfills -->
<script src="polyfill/anim-worklet.js"></script>

<script src="animator.js"></script>
/* animator.js (load a Worklet module) */
window.animationWorkletPolyfill.addModule('worklet.js')
    .then(()=> {
        // onWorkletLoaded()
    }).catch(console.error);

Animation Worklet itself

/* worklet.js - register and apply animations */
// Animators are classes registered in the worklet execution context
registerAnimator(
    'scroll-position-animator',// animator name
    class { // extends Web Animation
        constructor(options) {
            this.options = options;
        }
    
        // currentTime, KeyframeEffect and localTime concepts
        // from Web Animation API
        // animate function with animation frame logic   
        animate(currentTime, effect) {
            // scroll position can be taken from option params
            // const scrollPos = currentTime * this.options.scrollRange;

            // each effect will apply the animation options
            // from 0 to 100% scroll position in the scroll source
            effect.children.forEach((children) => {
                // currentTime is a Number,
                // which represent the vertical scroll position
                children.localTime = currentTime * 100;
            });
        }
});  

Animator

/* animator.js (onWorkletLoaded() ) */
const scrollPositionAnimation = // animator instance
  new WorkletAnimation(
    'scroll-position-animator', // animation animator name
    [ // animation effects
      new KeyframeEffect(scrollPositionElement, [ // scroll position
          {'transform': 'translateX(-100%)'}, // from
          {'transform': 'translateX(0%)'} // to
        ],
        {duration: 100, iterations: 1, fill: 'both'} // options
      ),
      new KeyframeEffect(subscribeElement, [ // size and opacity
          {'transform': 'scale(0.5)', 'opacity': 0}, // from
          {'transform': 'scale(1)','opacity': 1} // to
        ],
        {duration: 100, iterations: 1, fill: 'both'}) // options
    ],
    new ScrollTimeline({ // animation timeline
      scrollSource: document.querySelector('.page-wrapper'),
      orientation: 'vertical'
    })
  );
scrollPositionAnimation.play(); // start and apply the animation

Animation Worklet result

" endScrollOffset" and "timeRange" options

// animation options
{
 pageTimeline: new ScrollTimeline({
  scrollSource,
  orientation: 'vertical',
  endScrollOffset: '375px',
  timeRange: 375
}

Consclusions

Bright Reality

- start using CSS Custom Properties

- register Custom Properties from JS (when available) for performance improvements and progressive enhancement

- play with Animation Worklet using the polyfill

 

Bright Future

- experiment with Paint Worklet in Chrome

- stay tuned with CSS Houdini Group and specs
(Worklets, Layout, Font Metrics, Typed OM APIs etc.)

Thank you!

CSS Houdini: From CSS Custom Properties to JavaScript Worklets and back

By Serg Hospodarets

CSS Houdini: From CSS Custom Properties to JavaScript Worklets and back

Today CSS Custom Properties are supported in all the major browsers. Now it’s time to do the next step- to have an ability to register new Custom Properties from JavaScript and setup the browser how to work with them (e.g. real CSS polyfills). They should work with the same performance as the native CSS properties, being animatable and aligned with CSSOM. Custom Properties can be used as a bridge between CSS and JavaScript. Houdini Task force introduces specs and JavaScript Worklets to expose the interaction with previously fully internal browser rendering mechanisms (during Paint, Layout, Composite stages). All this brings Front-End development to the next level, parts of which are already available for the developers.

  • 24,591