A Practical Overview Of CSS Houdini

About The Author

Adrian Bece is a full-stack web developer with extensive eCommerce experience. He enjoys writing and talking about the latest and greatest technologies in web … More about Adrian ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

Houdini, an umbrella term for the collection of browser APIs, aims to bring significant improvements to the web development process and the development of CSS standards in general. Frontend developers will be able to extend the CSS with new features using JavaScript, hook into CSS rendering engine and tell the browser how to apply CSS during a render process. Houdini’s browser support is improving and some APIs are available for use today, so it’s a good time to become familiar with them and experiment. We are going to take a look at each part of Houdini, its current browser support and see how they can be used today using progressive enhancement.

It takes a long time for a new CSS feature or improvement to progress from an initial draft to a fully-supported and stable CSS feature that developers can use. JavaScript-based polyfills can be used as a substitute for the lack of browser support in order to use new CSS features before they’re officially implemented. But they are flawed in most cases. For example, scrollsnap-polyfill is one of several polyfills that can be used to fix browser support inconsistencies for the CSS Scroll Snap specification. But even that solution has some limitations, bugs and inconsistencies.

The potential downside to using polyfills is that they can have a negative impact on performance and are difficult to implement properly. This downside is related to the browser’s DOM and CSSOM. Browser creates a DOM (Document Object Model) from HTML markup and, similarly, it created CSSOM (CSS Object Model) from CSS markup. These two object trees are independent of one another. JavaScript works on DOM and has very limited access to CSSOM.

JavaScript Polyfill solutions run only after the initial render cycle has been completed, i.e. when both DOM and CSSOM have been created and the document has finished loading. After Polyfill makes changes to styles in the DOM (by inlining them), it causes the render process to run again and the whole page re-renders. Negative performance impact gets even more apparent if they rely on the requestAnimationFrame method or depend on user interactions like scroll events.

Another obstacle in web development is various constraints imposed by the CSS standards. For example, there are only a limited number of CSS properties that can be natively animated. CSS knows how to natively animate colors, but doesn’t know how to animate gradients. There has always been a need to innovate and create impressive web experiences by pushing the boundaries despite the tech limitations. That is why developers often tend to gravitate towards using less-than-ideal workarounds or JavaScript to implement more advanced styling and effects that are currently not supported by CSS such as masonry layout, advanced 3D effects, advanced animation, fluid typography, animated gradients, styled select elements, etc.

It seems impossible for CSS specifications to keep up with the various feature demands from the industry such as more control over animations, improved text truncation, better styling option for input and select elements, more display options, more filter options, etc.

What could be the potential solution? Give developers a native way of extending CSS using various APIs. In this article, we are going to take a look at how frontend developers can do that using Houdini APIs, JavaScript, and CSS. In each section, we’re going to examine each API individually, check its browser support and current specification status, and see how they can be implemented today using Progressive enhancement.

What Is Houdini?

Houdini, an umbrella term for the collection of browser APIs, aims to bring significant improvements to the web development process and the development of CSS standards in general. Developers will be able to extend the CSS with new features using JavaScript, hook into CSS rendering engine and tell the browser how to apply CSS during a render process. This will result in significantly better performance and stability than using regular polyfills.

Houdini specification consists of two API groups - high-level APIs and low-level APIs.

High-level APIs are closely related to the browser’s rendering process (style → layout → paint → composite). This includes:

  • Paint API
    An extension point for the browser’s paint rendering step where visual properties (color, background, border, etc.) are determined.
  • Layout API
    An extension point for the browser’s layout rendering step where element dimensions, position, and alignment are determined.
  • Animation API
    An extension point for browser’s composite rendering step where layers are drawn to the screen and animated.

Low-Level APIs form a foundation for high-level APIs. This includes:

  • Typed Object Model API
  • Custom Properties & Values API
  • Font Metrics API
  • Worklets

Some Houdini APIs are already available for use in some browsers with other APIs to follow suit when they’re ready for release.

The Future Of CSS

Unlike regular CSS feature specifications that have been introduced thus far, Houdini stands out by allowing developers to extend the CSS in a more native way. Does this mean that CSS specifications will stop evolving and no new official implementations of CSS features will be released? Well, that is not the case. Houdini’s goal is to aid the CSS feature development process by allowing developers to create working prototypes that can be easily standardized.

Additionally, developers will be able to share the open-source CSS Worklets more easily and with less need for browser-specific bugfixes.

Typed Object Model API

Before Houdini was introduced, the only way for JavaScript to interact with CSS was by parsing CSS represented as string values and modifying them. Parsing and overriding styles manually can be difficult and error-prone due to the value type needing to be changed back and forth and value unit needing to be manually appended when assigning a new value.

selectedElement.style.fontSize = newFontSize + "px"; // newFontSize = 20
console.log(selectedElement.style.fontSize); // "20px"

Typed Object Model (Typed OM) API adds more semantic meaning to CSS values by exposing them as typed JavaScript objects. It significantly improves the related code and makes it more performant, stable and maintainable. CSS values are represented by the CSSUnitValue interface which consists of a value and a unit property.

{
  value: 20, 
  unit: "px"
}

This new interface can be used with the following new properties:

  • computedStyleMap(): for parsing computed (non-inline) styles. This is a method of selected element that needs to be invoked before parsing or using other methods.
  • attributeStyleMap: for parsing and modifying inline styles. This is a property that is available on a selected element.
// Get computed styles from stylesheet (initial value)
selectedElement.computedStyleMap().get("font-size"); // { value: 20, unit: "px"}

// Set inline styles
selectedElement.attributeStyleMap.set("font-size", CSS.em(2)); // Sets inline style
selectedElement.attributeStyleMap.set("color", "blue"); // Sets inline style

// Computed style remains the same (initial value)
selectedElement.computedStyleMap().get("font-size"); // { value: 20, unit: "px"}

// Get new inline style
selectedElement.attributeStyleMap.get("font-size"); // { value: 2, unit: "em"}

Notice how specific CSS types are being used when setting a new numeric value. By using this syntax, many potential type-related issues can be avoided and the resulting code is more reliable and bug-free.

The get and set methods are only a small subset of all available methods defined by the Typed OM API. Some of them include:

  • clear: removes all inline styles
  • delete: removes a specified CSS property and its value from inline styles
  • has: returns a boolean if a specified CSS property is set
  • append: adds an additional value to a property that supports multiple values
  • etc.

Feature detection

var selectedElement = document.getElementById("example");

if(selectedElement.attributeStyleMap) {
  /* ... */
}

if(selectedElement.computedStyleMap) {
  /* ... */
}

W3C Specification Status

Browser Support

Google ChromeMicrosoft EdgeOpera BrowserFirefoxSafari
SupportedSupportedSupportedNot supportedPartial support (*)

* supported with “Experimental Web Platform features” or other feature flag enabled.

Data source: Is Houdini Ready Yet?

Custom Properties And Values API

The CSS Properties And Values API allows developers to extend CSS variables by adding a type, initial value and define inheritance. Developers can define CSS custom properties by registering them using the registerProperty method which tells the browsers how to transition it and handle fallback in case of an error.

CSS.registerProperty({ 
  name: "--colorPrimary",
  syntax: "<color>", 
  inherits: false,
  initialValue: "blue",
});

This method accepts an input argument that is an object with the following properties:

  • name: the name of the custom property
  • syntax: tells the browser how to parse a custom property. These are pre-defined values like <color>, <integer>, <number>, <length>, <percentage>, etc.
  • inherits: tells the browser whether the custom property inherits its parent’s value.
  • initialValue: tells the initial value that is used until it’s overridden and this is used as a fallback in case of an error.

In the following example, the <color> type custom property is being set. This custom property is going to be used in gradient transition. You might be thinking that current CSS doesn’t support transitions for background gradients and you would be correct. Notice how the custom property itself is being used in transition, instead of a background property that would be used for regular background-color transitions.

.gradientBox { 
  background: linear-gradient(45deg, rgba(255,255,255,1) 0%, var(--colorPrimary) 60%);
  transition: --colorPrimary 0.5s ease;
  /* ... */
}

.gradientBox:hover {
  --colorPrimary: red
  /* ... */
}

Browser doesn’t know how to handle gradient transition, but it knows how to handle color transitions because the custom property is specified as <color> type. On a browser that supports Houdini, a gradient transition will happen when the element is being hovered on. Gradient position percentage can also be replaced with CSS custom property (registered as <percentage> type) and added to a transition in the same way as in the example.

If registerProperty is removed and a regular CSS custom property is registered in a :root selector, the gradient transition won’t work. It’s required that registerProperty is used so the browser knows that it should treat it as color.

In the future implementation of this API, it would be possible to register a custom property directly in CSS.

@property --colorPrimary { 
  syntax: "<color>"; 
  inherits: false; 
  initial-value: blue;
}

Example

This simple example showcases gradient color and position transition on hover event using registered CSS custom properties for color and position respectively. Complete source code is available on the example repository.

Animated gradient color and position using Custom Properties & Values API. Delay for each property added for effect in CSS transition property. (Large preview)

Feature Detection

if (CSS.registerProperty) {
  /* ... */
}

W3C Specification Status

Browser Support

Google ChromeMicrosoft EdgeOpera BrowserFirefoxSafari
SupportedSupportedSupportedNot supportedNot supported

Data source: Is Houdini Ready Yet?

Font Metrics API

The Font Metrics API is still in a very early stage of development, so its specification may change in the future. In its current draft, Font Metrics API will provide methods for measuring dimensions of text elements that are being rendered on screen in order to allow developers to affect how text elements are being rendered on screen. These values are either difficult or impossible to measure with current features, so this API will allow developers to create text and font-related CSS features more easily. Multi-line dynamic text truncation is an example of one of those features.

W3C Specification Status

Browser Support

Google ChromeMicrosoft EdgeOpera BrowserFirefoxSafari
Not supportedNot supportedNot supportedNot supportedNot supported

Data source: Is Houdini Ready Yet?

Worklets

Before moving onto the other APIs, it’s important to explain the Worklets concept. Worklets are scripts that run during render and are independent of the main JavaScript environment. They are an extension point for rendering engines. They are designed for parallelism (with 2 or more instances) and thread-agnostic, have reduced access to the global scope and are called by the rendering engine when needed. Worklets can be run only on HTTPS (on production environment) or on localhost (for development purposes).

Houdini introduces following Worklets to extend the browser render engine:

  • Paint Worklet - Paint API
  • Animation Worklet - Animation API
  • Layout Worklet - Layout API

Paint API

The Paint API allows developers to use JavaScript functions to draw directly into an element’s background, border, or content using 2D Rendering Context, which is a subset of the HTML5 Canvas API. Paint API uses Paint Worklet to draw an image that dynamically responds to changes in CSS (changes in CSS variables, for example). Anyone familiar with Canvas API will feel right at home with Houdini’s Paint API.

There are several steps required in defining a Paint Worklet:

  1. Write and register a Paint Worklet using the registerPaint function
  2. Call the Worklet in HTML file or main JavaScript file using CSS.paintWorklet.addModule function
  3. Use the paint() function in CSS with a Worklet name and optional input arguments.

Let’s take a look at the registerPaint function which is used to register a Paint Worklet and define its functionality.

registerPaint("paintWorketExample", class {
  static get inputProperties() { return ["--myVariable"]; }
  static get inputArguments() { return ["<color>"]; }
  static get contextOptions() { return {alpha: true}; }

  paint(ctx, size, properties, args) {
    /* ... */
  }
});

The registerPaint function consists of several parts:

  • inputProperties:
    An array of CSS custom properties that the Worklet will keep track of. This array represents dependencies of a paint worklet.
  • inputArguments:
    An array of input arguments that can be passed from paint function from inside the CSS.
  • contextOptions: allow or disallow opacity for colors. If set to false, all colors will be displayed with full opacity.
  • paint: the main function that provides the following arguments:
    • ctx: 2D drawing context, almost identical to Canvas API’s 2D drawing context.
    • size: an object containing the width and height of the element. Values are determined by the layout rendering process. Canvas size is the same as the actual size of the element.
    • properties: input variables defined in inputProperties
    • args: an array of input arguments passed in paint function in CSS

After the Worklet has been registered, it needs to be invoked in the HTML file by simply providing a path to the file.

CSS.paintWorklet.addModule("path/to/worklet/file.js");

Any Worklet can also be added from an external URL (from a Content Delivery Network, for example) which makes them modular and reusable.

CSS.paintWorklet.addModule("https://url/to/worklet/file.js");

After the Worklet has been called, it can be used inside CSS using the paint function. This function accepts the Worklet’s registered name as a first input argument and each input argument that follows it is a custom argument that can be passed to a Worklet (defined inside Worklet’s inputArguments ). From that point, the browser determines when to call the Worklet and which user actions and CSS custom properties value change to respond to.

.exampleElement {
  /* paintWorkletExample - name of the worklet
     blue - argument passed to a Worklet */
  background-image: paint(paintWorketExample, blue);
}

Example

The following example showcases Paint API and general Worklet reusability and modularity. It’s using the ripple Worklet directly from Google Chrome Labs repository and runs on a different element with different styles. Complete source code is available on the example repository.

Ripple effect example (uses Ripple Worklet by Google Chrome Labs) (Large preview)

Feature detection

if ("paintWorklet" in CSS) {
  /* ... */
}


@supports(background:paint(paintWorketExample)){
  /* ... */
}

W3C Specification Status

Browser Support

Google ChromeMicrosoft EdgeOpera BrowserFirefoxSafari
SupportedSupportedSupportedNot supportedNot supported

Data source: Is Houdini Ready Yet?

Animation API

The Animation API extends web animations with options to listen to various events (scroll, hover, click, etc.) and improves performance by running animations on their own dedicated thread using an Animation Worklet. It allows for user action to control the flow of animation that runs in a performant, non-blocking way.

Like any Worklet, Animation Worklet needs to be registered first.

registerAnimator("animationWorkletExample", class {
  constructor(options) {
    /* ... */
  }
  animate(currentTime, effect) {
    /* ... */
  }
});

This class consists of two functions:

  • constructor: called when a new instance is created. Used for general setup.
  • animate: the main function that contains the animation logic. Provides the following input arguments:
    • currentTime: the current time value from the defined timeline
    • effect: an array of effects that this animation uses

After the Animation Worklet has been registered, it needs to be included in the main JavaScript file, animation (element, keyframes, options) needs to be defined and animation is instantiated with the selected timeline. Timeline concepts and web animation basics will be explained in the next section.

/* Include Animation Worklet */
await CSS.animationWorklet.addModule("path/to/worklet/file.js");;

/* Select element that's going to be animated */
const elementExample = document.getElementById("elementExample");

/* Define animation (effect) */
const effectExample = new KeyframeEffect(
  elementExample,  /* Selected element that's going to be animated */
  [ /* ... */ ],   /* Animation keyframes */
  { /* ... */ },   /* Animation options - duration, delay, iterations, etc. */
);

/* Create new WorkletAnimation instance and run it */
new WorkletAnimation(
  "animationWorkletExample"  /* Worklet name */
  effectExample,             /* Animation (effect) timeline */
  document.timeline,         /* Input timeline */
  {},                        /* Options passed to constructor */
).play();                    /* Play animation */

Timeline Mapping

Web animation is based on timelines and mapping of the current time to a timeline of an effect’s local time. For example, let’s take a look at a repeating linear animation with 3 keyframes (start, middle, last) that runs 1 second after a page is loaded (delay) and with a 4-second duration.

Effect timeline from the example would look like this (with the 4-second duration with no delay):

Effect timeline (4s duration)Keyframe
0msFirst keyframe - animation starts
2000msMiddle keyframe - animation in progress
4000msLast keyframe - animation ends or resets to first keyframe

In order to better understand effect.localTime, by setting its value to 3000ms (taking into account 1000ms delay), resulting animation is going to be locked to a middle keyframe in effect timeline (1000ms delay + 2000ms for a middle keyframe). The same effect is going to happen by setting the value to 7000ms and 11000ms because the animation repeats in 4000ms interval (animation duration).

animate(currentTime, effect) {
  effect.localTime = 3000; // 1000ms delay + 2000ms middle keyframe
}

No animation happens when having a constant effect.localTime value because animation is locked in a specific keyframe. In order to properly animate an element, its effect.localTime needs to be dynamic. It’s required for the value to be a function that depends on the currentTime input argument or some other variable.

The following code shows a functional representation of 1:1 (linear function) mapping of a timeline to effect local time.

animate(currentTime, effect) {
  effect.localTime = currentTime; // y = x linear function
}
Timeline (document.timeline)Mapped effect local timeKeyframe
startTime + 0ms (elapsed time)startTime + 0msFirst
startTime + 1000ms (elapsed time)startTime + 1000ms (delay) + 0msFirst
startTime + 3000ms (elapsed time)startTime + 1000ms (delay) + 2000msMiddle
startTime + 5000ms (elapsed time)startTime + 1000ms (delay) + 4000msLast / First
startTime + 7000ms (elapsed time)startTime + 1000ms (delay) + 6000msMiddle
startTime + 9000ms (elapsed time)startTime + 1000ms (delay) + 8000msLast / First

Timeline isn’t restricted to 1:1 mapping to effect’s local time. Animation API allows developers to manipulate the timeline mapping in animate function by using standard JavaScript functions to create complex timelines. Animation also doesn’t have to behave the same in each iteration (if animation is repeated).

Animation doesn’t have to depend on the document’s timeline which only starts counting milliseconds from the moment it’s loaded. User actions like scroll events can be used as a timeline for animation by using a ScrollTimeline object. For example, an animation can start when a user has scrolled to 200 pixels and can end when a user has scrolled to 800 pixels on a screen.

const scrollTimelineExample = new ScrollTimeline({
  scrollSource: scrollElement,  /* DOM element whose scrolling action is being tracked */
  orientation: "vertical",      /* Scroll direction */
  startScrollOffset: "200px",   /* Beginning of the scroll timeline */
  endScrollOffset: "800px",    /* Ending of the scroll timeline */
  timeRange: 1200,              /* Time duration to be mapped to scroll values*/
  fill: "forwards"              /* Animation fill mode */
});

...

The animation will automatically adapt to user scroll speed and remain smooth and responsive. Since Animation Worklets are running off the main thread and are connected to a browser’s rending engine, animation that depends on user scroll can run smoothly and be very performant.

Example

The following example showcases how a non-linear timeline implementation. It uses modified Gaussian function and applies translation and rotation animation with the same timeline. Complete source code is available on the example repository.

Animation created with Animation API which is using modified Gaussian function time mapping (Large preview)

Feature Detection

if (CSS.animationWorklet) {
  /* ... */
}

W3C Specification Status

Browser Support

Google ChromeMicrosoft EdgeOpera BrowserFirefoxSafari
Partial support (*)Partial support (*)Partial support (*)Not supportedNot supported

* supported with “Experimental Web Platform features” flag enabled.

Data source: Is Houdini Ready Yet?

Layout API

The Layout API allows developers to extend the browser’s layout rendering process by defining new layout modes that can be used in display CSS property. Layout API introduces new concepts, is very complex and offers a lot of options for developing custom layout algorithms.

Similarly to other Worklets, the layout Worklet needs to be registered and defined first.

registerLayout('exampleLayout', class {
  static get inputProperties() { return ['--exampleVariable']; }

  static get childrenInputProperties() { return ['--exampleChildVariable']; }

  static get layoutOptions() {
    return {
      childDisplay: 'normal',
      sizing: 'block-like'
    };
  }

  intrinsicSizes(children, edges, styleMap) {
    /* ... */
  }

  layout(children, edges, constraints, styleMap, breakToken) {
    /* ... */
  }
});

Worklet register contains the following methods:

  • inputProperties:
    An array of CSS custom properties that the Worklet will keep track of that belongs to a Parent Layout element, i.e. the element that calls this layout. This array represents dependencies of a Layout Worklet.
  • childrenInputProperties:
    An array of CSS custom properties that the Worklet will keep track of that belong to child elements of a Parent Layout element, i.e. the children of the elements that set this layout.
  • layoutOptions: defines the following layout properties:
    • childDisplay: can have a pre-defined value of block or normal. Determines if the boxes will be displayed as blocks or inline.
    • sizing: can have a pre-defined value of block-like or manual. It tells the browser to either pre-calculate the size or not to pre-calculate (unless a size is explicitly set), respectively.
  • intrinsicSizes: defines how a box or its content fits into a layout context.
    • children: child elements of a Parent Layout element, i.e. the children of the element that call this layout.
    • edges: Layout Edges of a box
    • styleMap: typed OM styles of a box
  • layout: the main function that performs a layout.
    • children: child elements of a Parent Layout element, i.e. the children of the element that call this layout.
    • edges: Layout Edges of a box
    • constraints: constraints of a Parent Layout
    • styleMap: typed OM styles of a box
    • breakToken: break token used to resume a layout in case of pagination or printing.

Like in the case of a Paint API, the browser rendering engine determines when the paint Worklet is being called. It only needs to be added to an HTML or main JavaScript file.

CSS.layoutWorklet.addModule('path/to/worklet/file.js');

And, finally, it needs to be referenced in a CSS file

.exampleElement {
  display: layout(exampleLayout);
}

How Layout API Performs Layout

In the previous example, exampleLayout has been defined using the Layout API.

.exampleElement {
  display: layout(exampleLayout);
}

This element is called a Parent Layout that is enclosed with Layout Edges which consists of paddings, borders and scroll bars. Parent Layout consists of child elements which are called Current Layouts. Current Layouts are the actual target elements whose layout can be customized using the Layout API. For example, when using display: flex; on an element, its children are being repositioned to form the flex layout. This is similar to what is being done with the Layout API.

Each Current Layout consists of Child Layout which is a layout algorithm for the LayoutChild (element, ::before and ::after pseudo-elements) and LayoutChild is a CSS generated box that only contains style data (no layout data). LayoutChild elements are automatically created by browser rendering engine on style step. Layout Child can generate a Fragment which actually performs layout render actions.

Example

Similarly to the Paint API example, this example is importing a masonry layout Worklet directly from Google Chrome Labs repository, but in this example, it’s used with image content instead of text. Complete source code is available on the example repository.

Masonry layout example (uses Masonry Worklet by Google Chrome Labs (Large preview)

Feature Detection

if (CSS.layoutWorklet) {
  /* ... */
}

W3C Specification Status

Browser Support

Google ChromeMicrosoft EdgeOpera BrowserFirefoxSafari
Partial support (*)Partial support (*)Partial support (*)Not supportedNot supported

* supported with “Experimental Web Platform features” flag enabled.

Data source: Is Houdini Ready Yet?

Houdini And Progressive Enhancement

Even though CSS Houdini doesn’t have optimal browser support yet, it can be used today with progressive enhancement in mind. If you are unfamiliar with Progressive enhancement, it would be worth to check out this handy article which explains it really well. If you decide on implementing Houdini in your project today, there are few things to keep in mind:

  • Use feature detection to prevent errors.
    Each Houdini API and Worklet offers a simple way of checking if it’s available in the browser. Use feature detection to apply Houdini enhancements only to browsers that support it and avoid errors.
  • Use it for presentation and visual enhancement only.
    Users that are browsing a website on a browser that doesn’t yet support Houdini should have access to the content and core functionality of the website. User experience and the content presentation shouldn’t depend on Houdini features and should have a reliable fallback.
  • Make use of a standard CSS fallback.
    For example, regular CSS Custom Properties can be used as a fallback for styles defined using Custom Properties & Values API.

Focus on developing a performant and reliable website user experience first and then use Houdini features for decorative purposes as a progressive enhancement.

Conclusion

Houdini APIs will finally enable developers to keep the JavaScript code used for style manipulation and decoration closer to the browser’s rendering pipeline, resulting in better performance and stability. By allowing developers to hook into the browser rendering process, they will be able to develop various CSS polyfills that can be easily shared, implemented and, potentially, added to CSS specification itself. Houdini will also make developers and designers less constrained by the CSS limitations when working on styling, layouts, and animations, resulting in new delightful web experiences.

CSS Houdini features can be added to projects today, but strictly with progressive enhancement in mind. This will enable browsers that do not support Houdini features to render the website without errors and offer optimal user experience.

It’s going to be exciting to watch what the developer community will come up with as Houdini gains traction and better browser support. Here are some awesome examples of Houdini API experiments from the community:

References

Further Reading

Smashing Editorial (ra, il, mrn)