CSS Houdini

Houdini is a series of browser API proposal for extending CSS. It would allow developers to make highly-performant CSS polyfills, or even to create new CSS layout models like CSS grid or flexbox.

Houdini provides three main modes of implementing properties: the layout, paint, and animation APIs.

Layout

The layout API is what you would use to recreate flexbox, or else some other kind of custom layout. The sample below shows how it’s done – you define mylayout though the registerLayout function. You can define properties that serve as variable inputs to your layout, and write the code that determines how children in the layout are positioned.

registerLayout(
  "example",
  class {
    static inputProperties = ["--foo"];
    static childrenInputProperties = ["--bar"];
    static layoutOptions = {
      childDisplay: "normal",
      sizing: "block-like"
    };

    async intrinsicSizes(children, edges, styleMap) {
      // Intrinsic sizes code goes here.
    }

    async layout(children, edges, constraints, styleMap, breakToken) {
      // Layout code goes here.
    }
  }
);

In flexbox, inputProperties include align-items and childrenInputProperties align-self. Your custom variables have the same power to affect how items and their children are laid out, depending on how you want to use them.

The API controls much of the lifecycle of layout itself. You get to define:

  • The element’s min and max size, given the children’s sizes
  • The available space for its children, given the element’s size
  • The positions of each child, given the children’s sizes
registerLayout(
  "block-like",
  class {
    async intrinsicSizes(children, edges, styleMap) {
      const childrenSizes = await Promise.all(
        children.map(child => {
          return child.intrinsicSizes();
        })
      );

      const maxContentSize =
        childrenSizes.reduce((max, childSizes) => {
          return Math.max(max, childSizes.maxContentSize);
        }, 0) + edges.inline;

      const minContentSize =
        childrenSizes.reduce((max, childSizes) => {
          return Math.max(max, childSizes.minContentSize);
        }, 0) + edges.inline;

      return { maxContentSize, minContentSize };
    }

    async layout(children, edges, constraints, styleMap) {
      // Determine our (inner) available size.
      const availableInlineSize = constraints.fixedInlineSize - edges.inline;
      const availableBlockSize = constraints.fixedBlockSize
        ? constraints.fixedBlockSize - edges.block
        : null;

      const childFragments = [];
      const childConstraints = { availableInlineSize, availableBlockSize };

      const childFragments = await Promise.all(
        children.map(child => {
          return child.layoutNextFragment(childConstraints);
        })
      );

      let blockOffset = edges.blockStart;
      for (let fragment of childFragments) {
        // Position the fragment in a block like manner, centering it in the
        // inline direction.
        fragment.blockOffset = blockOffset;
        fragment.inlineOffset = Math.max(
          edges.inlineStart,
          (availableInlineSize - fragment.inlineSize) / 2
        );

        blockOffset += fragment.blockSize;
      }

      const autoBlockSize = blockOffset + edges.blockEnd;

      return {
        autoBlockSize,
        childFragments
      };
    }
  }
);

Paint

The paint API gives an SVG- or canvas-like drawing API that you can control via CSS properties. The following draws a circle with the radius given by border-radius-reverse:

class BorderRadiusReversePainter {
  static get inputProperties() {
    return ["--border-radius-reverse", "--border-radius-reverse-color"];
  }

  clearCircle(context, x, y, radius) {
    context.save();
    context.beginPath();
    context.arc(x, y, radius, 0, 2 * Math.PI, true);
    context.clip();
    context.clearRect(x - radius, y - radius, radius * 2, radius * 2);
    context.restore();
  }

  paint(ctx, geom, props) {
    const radiusValue = Number(props.get("--border-radius-reverse").toString());

    ctx.fillStyle = props.get("--border-radius-reverse-color").toString();
    ctx.fillRect(0, 0, geom.width, geom.height);

    this.clearCircle(ctx, 0, 0, radiusValue); // Left top
    this.clearCircle(ctx, geom.width, geom.height, radiusValue); // Right bottom
    this.clearCircle(ctx, 0, geom.height, radiusValue); // Left bottom
    this.clearCircle(ctx, geom.width, 0, radiusValue); // Right top
  }
}

registerPaint("border-radius-reverse", BorderRadiusReversePainter);

Animation

The WorkletAnimation API provides a way for CSS animation effects to hook into user input. It allows you to define keyframes and a way to a way to translate the current time into the progress of the animation, which runs in a worklet thread:

registerAnimator(
  "my-awesome-animator",
  class Passthrough extends StatelessAnimator {
    animate(currentTime, effect) {
      // The simplest custom animator that does exactly what regular animations do!
      effect.localTime = currentTime;
    }
  }
);
// Load your custom animator in the worklet
await CSS.animationWorklet.addModule("animator.js");

const effect = new KeyframeEffect(
  targetEl,
  { transform: ["translateX(0)", "translateX(50vw)"] },
  { duration: 1000 }
);
const animation = new WorkletAnimation("my-awesome-animator", effect);
animation.play();

As well as defining your own timelines (in animate), you can use provided animation timelines like ScrollTimeline to translate user scroll input into animation progress.

Here’s a full example, with more available in the explainer:

[
  "--avatar-size",
  "--avatar-border",
  "--header-height",
  "--font-base",
  "--bar-height",
  "--spacer"
].forEach(name => {
  CSS.registerProperty({
    name,
    syntax: "<length>",
    initialValue: "0px",
    inherits: true
  });
});

async function init() {
  await animationWorklet.addModule("twitter-header-animator.js");
  const scrollSource = document.body;
  const bar = document.querySelector(".bar");
  const avatar = document.querySelector(".profile .avatar");
  const follow = document.querySelector(".profile .follow");
  const name = document.querySelector(".profile .name");
  const timeRange = 1000;
  const scrollTimeline = new ScrollTimeline({ scrollSource, timeRange });

  const barEffect = new KeyframeEffect(bar, [{ opacity: 0 }, { opacity: 1 }], {
    duration: /*timeWhenAvatarTouchesTop*/ 0,
    fill: "both"
  });
  new WorkletAnimation("twitter-header", barEffect, scrollTimeline, []).play();
  const avatarEffect = new KeyframeEffect(
    avatar,
    [
      { transform: `translateY(0) scale(1)` },
      {
        transform: `translateY(0px) scale(${0 /*targetAvatarScale*/})`,
        offset: 0 /*timeWhenAvatarTouchesTop/timeRange*/
      },
      {
        transform: `translateY(${0 /*maxAvatarOffset*/}px) scale(${
          0 /*targetAvatarScale*/
        })`
      }
    ],
    {
      duration: timeRange,
      fill: "both"
    }
  );
  new WorkletAnimation(
    "twitter-header",
    avatarEffect,
    scrollTimeline,
    []
  ).play();

  const followEffect = new KeyframeEffect(
    follow,
    [
      { transform: `translateY(0)` },
      {
        transform: `translateY(0)`,
        offset: 0 /*timeWhenFollowTouchesTop/range*/
      },
      { transform: `translateY(${0 /*maxAvatarOffset*/}px)` }
    ],
    {
      duration: timeRange,
      fill: "both"
    }
  );
  new WorkletAnimation(
    "twitter-header",
    followEffect,
    scrollTimeline,
    []
  ).play();

  const nameEffect = new KeyframeEffect(
    name,
    [
      { transform: `translateY(0)` },
      {
        transform: `translateY(0)`,
        offset: 0 /*timeWhenFollowTouchesTop/timeRange*/
      },
      {
        transform: `translateY(0) translateX(${
          0 /*scrollSourceStyles.get('--bar-height').value*/
        }px)`,
        offset: 0 /*timeWhenNameTouchesTop/timeRange*/
      },
      {
        transform: `translateY(${0 /*maxNameOffset*/}px) translateX(${
          0 /*scrollSourceStyles.get('--bar-height').value*/
        }px)`
      }
    ],
    {
      duration: timeRange,
      fill: "both"
    }
  );
  new WorkletAnimation("twitter-header", nameEffect, scrollTimeline, []).play();

  function updateTimings() {
    const scrollSourceStyles = document.body.computedStyleMap();
    const viewportHeight = scrollSource.clientHeight;
    const maxScroll = scrollSource.scrollHeight - viewportHeight;

    const avatarDistanceFromTop =
      scrollSourceStyles.get("--header-height").value / 2 -
      scrollSourceStyles.get("--avatar-size").value / 2 -
      scrollSourceStyles.get("--avatar-border").value;
    const timeWhenAvatarTouchesTop =
      (avatarDistanceFromTop / maxScroll) * timeRange;
    const maxAvatarOffset = maxScroll - avatarDistanceFromTop;
    const targetAvatarScale =
      scrollSourceStyles.get("--bar-height").value /
      (scrollSourceStyles.get("--avatar-size").value +
        scrollSourceStyles.get("--avatar-border").value * 2);

    const avatarEffectKeyFrames = avatarEffect.getKeyframes();
    avatarEffectKeyFrames[1].transform = `translateY(0px) scale(${targetAvatarScale})`;
    avatarEffectKeyFrames[1].offset = timeWhenAvatarTouchesTop / timeRange;
    avatarEffectKeyFrames[2].transform = `translateY(${maxAvatarOffset}px) scale(${targetAvatarScale})`;
    avatarEffect.setKeyframes(avatarEffectKeyFrames);

    barEffect.duration = timeWhenAvatarTouchesTop;

    const followDistanceFromTop =
      scrollSourceStyles.get("--header-height").value / 2 +
      scrollSourceStyles.get("--spacer").value / 2;
    const timeWhenFollowTouchesTop =
      (followDistanceFromTop / maxScroll) * timeRange;
    const maxFollowOffset = maxScroll - followDistanceFromTop;
    const followEffectKeyFrames = followEffect.getKeyframes();
    followEffectKeyFrames[1].offset = timeWhenFollowTouchesTop / timeRange;
    followEffectKeyFrames[2].transform = `translateY(${maxFollowOffset}px)`;
    followEffect.setKeyframes(followEffectKeyFrames);

    const nameDistanceFromTop =
      name.offsetTop - scrollSourceStyles.get("--spacer").value;
    const timeWhenNameTouchesTop =
      (nameDistanceFromTop / maxScroll) * timeRange;
    const maxNameOffset = maxScroll - nameDistanceFromTop;
    const nameEffectKeyFrames = nameEffect.getKeyframes();
    const nameLeftOffset =
      scrollSourceStyles.get("--bar-height").value +
      scrollSourceStyles.get("--spacer").value / 2;
    nameEffectKeyFrames[1].offset = timeWhenAvatarTouchesTop / timeRange;
    nameEffectKeyFrames[2].transform = `translateY(0) translateX(${nameLeftOffset}px)`;
    nameEffectKeyFrames[2].offset = timeWhenNameTouchesTop / timeRange;
    nameEffectKeyFrames[3].transform = `translateY(${maxNameOffset}px) translateX(${nameLeftOffset}px)`;
    nameEffect.setKeyframes(nameEffectKeyFrames);
  }
  updateTimings();
  window.addEventListener("resize", _ => updateTimings());
}
init();

Other Components

Worklets

registerLayout and registerPaint register worklets. Worklets are like webworkers, running in a separate browser process, but the browser’s layout engine controls when they’re called.

CSS Object Model

A separate proposal, the CSS object model, provides a mapping of CSS values to Javascript objects, like CSS.number(0.5) or CSS.em(2). This is supposed to improve performance compared to implementing typing in pure JS, which interfaces with CSS via strings and must convert them back and forth.

You can use this to get or set properties directly on on an element via its attributeStyleMap:

myElement.attributeStyleMap.set("opacity", CSS.number(3));
myElement.attributeStyleMap.set("z-index", CSS.number(15.4));

console.log(myElement.attributeStyleMap.get("opacity").value); // 3
console.log(myElement.attributeStyleMap.get("z-index").value); // 15.4

CSS.registerProperty

New properties are defined via the custom properties API, which allow you more or less to mimic any already existing CSS property. For instance, you make them limited to a single DOM node rather than cascading down the tree:

CSS.registerProperty({
  name: "--foo", // String, name of the custom property
  syntax: "<color>", // String, how to parse this property. Defaults to *
  inherits: false, // Boolean, if true should inherit down the DOM tree
  initialValue: "black" // String, initial value of this property
});

Here’s how that would look in a CSS rule:

.my-layout-element {
  display: layout("mylayout");
  foo: red;
}