css and layoutsanimayions

Using CSS Houdini: Create Custom Effects Not Available in Regular CSS

Using CSS Houdini: Create Custom Effects Not Available in Regular CSS

Try doing something that looks trivial: smoothly animate the angle in a conic-gradient() on hover. You define a custom property --kat: 0deg, add transition: --kat 0.4s, hover your mouse — and nothing happens. The gradient jumps instantly, as if transition didn't exist at all. This isn't a bug in your code. To the browser, --kat is just an opaque string — it has no idea it's an angle, so there's no way to interpolate it between 0deg and 180deg. The browser can interpolate numbers, colors, lengths — because it knows their types. Custom properties, unlike built-in CSS properties, have no type at all. This exact gap is where CSS Houdini lives.

What Houdini Actually Is

This is a common misconception: Houdini isn't one technology, or even one specification. It's an umbrella covering several independent proposals at the W3C, unified by a single idea — expose fragments of the rendering engine (value parsing, layout, painting, animation) that used to be completely hidden from developers. Instead of waiting for CSS to gain a ready-made property that does exactly what you need, you get a low-level hook into a specific stage of that process.

These hooks are implemented as worklets — small JavaScript modules, conceptually close to Web Workers, but designed specifically for the rendering pipeline. A worklet runs outside the main thread, has no access to window, document, or the DOM, and communicates only through a strictly defined contract (e.g. paint(ctx, size, properties)). This isn't a limitation born of laziness on the spec authors' part — it's a deliberate architectural decision. Thanks to that isolation, the engine can run worklets in parallel, on separate threads, and cache the result without any risk that your code will touch something it shouldn't. That same isolation is why you won't find Date, performance.now(), or fetch inside a worklet — restricting access to precise timing and the network is a deliberate defense against timing/fingerprinting attacks from code that renders the page.

The individual parts of this umbrella are today at completely different stages: one has long been part of Baseline, another only works in Chrome, and yet another, after a decade, is still experimental. This distinction is crucial — and most introductory articles about Houdini skip over it, treating the whole thing as a single, ready-to-use technology.

The Only Part of Houdini Really Worth Using Today: @property

The CSS Properties and Values API solves exactly the problem this article opened with. You register a custom property with a specific type (syntax), an initial value, and information about whether it should be inherited — and from that moment on, the browser treats it as a full-fledged, animatable value instead of a string.

html

<div class="ring"></div>

css

@property --kat {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.ring {
width: 200px;
height: 200px;
border-radius: 50%;
background: conic-gradient(from var(--kat), #ff4d4d, #4d79ff, #4dff88, #ff4d4d);
transition: --kat 0.6s ease;
}
.ring:hover {
--kat: 180deg;
}

This example genuinely does something plain CSS can't — without @property, the transition above simply wouldn't work, exactly as in the scenario at the start of this article. syntax: '<angle>' tells the browser that --kat is an angle, so it can interpolate it smoothly; inherits: false prevents accidental inheritance by descendant elements, which with custom properties can be a source of hard-to-track bugs.

The same effect can be achieved from JavaScript, which makes sense when you're registering properties dynamically (e.g. generated from data) rather than hardcoding them in a stylesheet:

javascript

if ('registerProperty' in CSS) {
CSS.registerProperty({
name: '--kat',
syntax: '<angle>',
inherits: false,
initialValue: '0deg',
});
}

This is the only part of Houdini that has Baseline status today — it works natively in Chrome, Firefox (from version 128), and Safari (from 16.4), with no polyfills and no @supports. If there's just one thing from all of Houdini you should remember and adopt, it's this one.

CSS Paint API: A Worklet That Actually Paints

The Paint API lets you replace background with a paint() function that draws directly on the element, using a trimmed-down interface reminiscent of Canvas 2D (PaintRenderingContext2D) — without fillText, drawImage, or pixel reading. What sets this approach apart from plain drawing on a <canvas> is inputProperties: the worklet declares which custom properties it wants to watch, and repaints itself automatically only when one of them changes — without a single line of JavaScript responsible for listening to events.

Below is a pattern of warning diagonal stripes, whose angle is controlled by the same, previously registered --kat property (or rather its local variant, --stripe-angle) — which shows how @property and the Paint API naturally complement each other.

html

<div class="hazard"></div>

css

@property --stripe-angle {
syntax: '<angle>';
inherits: false;
initial-value: 45deg;
}
.hazard {
width: 100%;
height: 120px;
--stripe-angle: 45deg;
background: paint(hazardStripes);
transition: --stripe-angle 0.4s ease;
}
.hazard:hover {
--stripe-angle: 135deg;
}

javascript

// main.js
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('hazard-stripes.js');
}

javascript

// hazard-stripes.js
class HazardStripesPainter {
static get inputProperties() {
return ['--stripe-angle'];
}
paint(ctx, size, properties) {
const angle = properties.get('--stripe-angle').to('deg').value;
const stripeWidth = 24;
const { width, height } = size;
const diagonal = Math.sqrt(width ** 2 + height ** 2) * 2;
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((angle * Math.PI) / 180);
ctx.translate(-diagonal, -diagonal);
let stripeIndex = 0;
for (let x = 0; x < diagonal * 2; x += stripeWidth) {
ctx.fillStyle = stripeIndex % 2 === 0 ? '#111111' : '#f5c400';
ctx.fillRect(x, 0, stripeWidth, diagonal * 2);
stripeIndex++;
}
ctx.restore();
}
}
registerPaint('hazardStripes', HazardStripesPainter);

It's worth paying attention to properties.get('--stripe-angle').to('deg').value — this is the CSS Typed OM, another member of the same API family. Instead of parsing a string by hand (parseFloat, trimming "deg"), you get a typed CSSUnitValue that you explicitly convert to whatever unit you need. Because the angle of the drawn stripes depends solely on the custom property declared in inputProperties, the engine knows exactly when a repaint is needed — changing an unrelated CSS property elsewhere on the page won't trigger paint() again.

The Layout API and Animation Worklet: The Reality in 2026

This is where most introductory articles about Houdini go quiet. The CSS Layout API — the third pillar of the same specification, letting you define your own layout algorithm (the equivalent of a custom display: grid) — has existed in the draft since 2015 and, a decade later, is still experimental. No stable browser has implemented it natively; it's available only behind flags or in Chrome origin trials. If you come across an article showing display: layout(name) as a ready-to-use solution, check the publication date — it's probably a demo from an origin trial from years ago, not something you can run in a user's browser today.

The Animation Worklet was meant to give full control over scroll-driven animations, running off the main thread. In practice only Chromium supports it. More importantly, its main use case has stopped being a reason to reach for Houdini at all, because CSS gained a native solution to the same problem: animation-timeline: scroll() now lets you tie an animation to scrolling without a single line of JavaScript and without worklets, with growing browser support. This is a good illustration of a broader pattern: some of the reasons Houdini was created in the first place have, over time, made their way straight into the CSS spec, instead of staying a low-level API for developers.

When It Actually Makes Sense in Practice

  • Use @property freely, without any safeguards — it has Baseline status and needs neither @supports nor a polyfill.
  • Treat the Paint API as progressive enhancement — wrap it in @supports(background: paint(x)) with a sensible plain-CSS fallback (e.g. a regular gradient) for Firefox, or reach for the css-paint-polyfill if the effect needs to work everywhere.
  • Don't plan a production feature around the Layout API — it's still an experiment with no real browser support, good for tinkering, not for a roadmap.
  • Remember that paint() isn't "free" CSS — it's your JavaScript code, re-run every time a declared inputProperties value changes. Expensive computations (generating noise, complex procedural patterns) need to be optimized exactly like a rendering loop on a <canvas> — it's easy to accidentally end up with an animation that repaints the entire element frame by frame on the main layout thread.

Summary

Houdini isn't a single "use it or not" decision — it's three or four separate decisions with different levels of risk. First, @property solves a real, concrete problem (animating custom properties) and is today just as safe to use as any other CSS property — it's the only part of this ecosystem worth adopting without hesitation. Second, the Paint API gives you real power (a worklet that repaints only when its declared dependencies change, with no manual event listening), but it requires a deliberate plan for browsers without support — it's a tool for progressive enhancement, not for critical features. Third, the Layout API and Animation Worklet are a good example of how a specification alone doesn't guarantee adoption — after years, one is still waiting to be implemented, and the other has been partly superseded by simpler, native CSS. Before you reach for Houdini, check which of these three baskets the specific part you need actually falls into.