CSS Houdini is a collection of low-level browser APIs that expose parts of the
CSS rendering engine to JavaScript. Before Houdini, browser vendors implemented
new CSS features and developers waited years for support. Houdini lets you
implement new CSS capabilities yourself — custom paint operations, animatable custom
properties with full type information, and a typed JavaScript interface to the
CSS Object Model — today, in any supporting browser.
Support is uneven across APIs. The Paint API and
@property / CSS.registerProperty() are the most
widely supported and production-ready. The Layout Worklet and Animation Worklet
are Chrome-only and not covered in detail here. Always check support before
shipping Houdini features — and always include a CSS fallback.
Paint API
Register a JavaScript worklet that draws into a canvas-like context. The output can be used anywhere CSS expects an image — background-image, border-image, mask-image. Inputs come from CSS custom properties.
Chrome ✓Edge ✓Firefox —Safari —
Properties & Values API
Register a custom property with a type, initial value, and inheritance flag — via @property in CSS or CSS.registerProperty() in JS. Enables transitions and animations on custom properties used in gradients, transforms, etc.
Chrome ✓Edge ✓Firefox ✓Safari ✓
Typed Object Model
A typed JavaScript API for reading and writing CSS values — attributeStyleMap and computedStyleMap() instead of element.style. Values are objects (CSSUnitValue, CSSMathSum) rather than raw strings, making arithmetic safe.
Chrome ✓Edge ✓Firefox —Safari —
Layout API
Write custom layout algorithms as a worklet — similar to how browsers implement flexbox and grid internally. Very powerful but highly experimental. Not production-ready.
Chrome flagFirefox —Safari —
Animation Worklet
Run animation logic off the main thread in a worklet. Use for scroll-linked animations and time-based animations that need to be jank-free. Partially superseded by the CSS Scroll-Driven Animations spec.
Chrome onlyFirefox —Safari —
Parser API
Parse arbitrary CSS strings from JavaScript, getting back a typed AST. Useful for tooling and polyfills but not yet widely useful for production code. Very early stage.
Not yet shipped
1. @property and CSS.registerProperty()
Unregistered CSS custom properties are treated as opaque strings by the browser.
The browser cannot interpolate between two string values — so transitions and
animations on custom properties used inside gradients or transforms simply
jump rather than tween. @property fixes this by giving the browser
the type information it needs to interpolate correctly.
Unregistered — hover me (no animation)
Hover — gradient position jumps
Registered @property — hover me (smooth)
Hover — gradient position animates
/* ── @property — CSS syntax (Baseline 2024) ─────────────────── */@property--highlight-pos{syntax:'<percentage>';initial-value:0%;inherits:false;}.card{--highlight-pos:0%;background:radial-gradient(circle at var(--highlight-pos) 50%,oklch(0.55 0.22 248 / 0.3),transparent 60%),#161b22;transition:--highlight-pos0.4s ease;/* Without @property: transition has no effect — browser sees a string jump *//* With @property: browser knows it's a <percentage> — interpolates correctly */}.card:hover{--highlight-pos:100%;}/* ── JavaScript equivalent ──────────────────────────────────── */CSS.registerProperty({name:'--highlight-pos',syntax:'<percentage>',initialValue:'0%',inherits:false,});/* Same effect as @property. Use JS form when registering dynamically. *//* Use @property in CSS when you want the registration collocated with the rule. *//* ── Supported syntax strings ───────────────────────────────── *//* '<length>' px, em, rem, vw, etc. *//* '<number>' unitless numbers *//* '<percentage>' 0%–100% *//* '<length-percentage>' calc(50px + 10%) *//* '<color>' any CSS color *//* '<image>' url(), gradient functions *//* '<angle>' deg, rad, turn *//* '<time>' s, ms *//* '<resolution>' dpi, dpcm, dppx *//* '<transform-list>' translate(), rotate(), etc. *//* '<custom-ident>' unquoted identifier *//* '*' any value — same as unregistered *//* 'red | blue | green' explicit keyword list *//* '<color>+' space-separated list of colors *//* '<color>#' comma-separated list of colors */
Animating custom properties — practical patterns
/* ── Animating a gradient angle ─────────────────────────────── */@property--gradient-angle{syntax:'<angle>';initial-value:0deg;inherits:false;}@keyframesspin-gradient{to{--gradient-angle:360deg;}}.spinning-card{background:conic-gradient(fromvar(--gradient-angle),oklch(0.55 0.22 248),oklch(0.55 0.22 300),oklch(0.55 0.22 248));animation:spin-gradient 4s linear infinite;}/* ── Animating a color token ─────────────────────────────────── */@property--accent-color{syntax:'<color>';initial-value:oklch(0.60 0.18 248);inherits:true;}.theme-toggle{transition:--accent-color0.6s ease;}.theme-toggle.warm{--accent-color:oklch(0.65 0.20 40);/* smooth transition to warm amber */}/* ── Animating a transform component ────────────────────────── */@property--rotate-y{syntax:'<angle>';initial-value:0deg;inherits:false;}.flip-card{transform:rotateY(var(--rotate-y));transition:--rotate-y0.5s ease;}.flip-card:hover{--rotate-y:180deg;}
2. The Paint API
The Paint API lets you write a JavaScript class that draws to a
PaintRenderingContext2D — a subset of the Canvas 2D API — and use
the output wherever CSS expects an <image>. The worklet runs in
a separate thread with no DOM access, keeping the main thread free.
checkered()
CSS-controllable checkerboard. Cell size and colors come from custom properties.
polka-dots()
Dot grid with variable spacing, size, and color. Adapts to element dimensions.
diagonal-lines()
Diagonal stripe pattern — angle, spacing, and opacity from custom properties.
glow-border()
Top-edge glow that responds to --glow-color. No pseudo-elements needed.
noise-bg()
Perlin-noise texture that would be impossible with pure CSS gradients.
circular-progress()
Progress arc drawn via paint worklet. --progress controls fill amount.
/* ── Registering and using a paint worklet ──────────────────── *//* Step 1: Write the worklet in a separate JS file (checkerboard.js) *//* The file must be served with a JS MIME type, same origin, HTTPS *//* checkerboard.js */class CheckerboardPainter {staticget inputProperties() {return['--cb-size','--cb-color-a','--cb-color-b'];}paint(ctx, size, properties) {const cellSize =parseInt(properties.get('--cb-size'))||20;const colorA = properties.get('--cb-color-a').toString().trim()||'#fff';const colorB = properties.get('--cb-color-b').toString().trim()||'#000';const cols =Math.ceil(size.width / cellSize);const rows =Math.ceil(size.height / cellSize);for(let r =0; r < rows; r++) {for(let c =0; c < cols; c++) {
ctx.fillStyle =(r + c)%2=== 0? colorA : colorB;
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);}}}}registerPaint('checkerboard', CheckerboardPainter);/* ─────────────────────────────────────────────────────────────── *//* Step 2: Register the worklet from the main JS file */if('paintWorklet'in CSS) {
CSS.paintWorklet.addModule('/worklets/checkerboard.js');}/* ─────────────────────────────────────────────────────────────── *//* Step 3: Use it in CSS with paint() */.checker-bg{--cb-size:24;/* unitless int for the worklet */--cb-color-a:oklch(0.20 0.02 248);--cb-color-b:oklch(0.15 0.02 248);background:paint(checkerboard);}/* paint() also works with border-image and mask-image */.fancy-border{border:12px solid transparent;border-image:paint(gradient-border)12;}
inputArguments — passing extra values directly
/* inputArguments lets you pass values directly in paint() rather than via custom props *//* Useful for one-off parameterisation without polluting the custom property namespace */class CircleProgressPainter {staticget inputProperties() {return['--progress-color'];}staticget inputArguments() {return['<number>'];}paint(ctx, size, properties, args) {const progress = args[0].value /100;/* 0–100 → 0–1 */const cx = size.width /2;const cy = size.height /2;const r =Math.min(cx, cy)-8;
ctx.strokeStyle ='#21262d';
ctx.lineWidth =8;
ctx.beginPath();
ctx.arc(cx, cy, r,0,Math.PI*2);
ctx.stroke();
ctx.strokeStyle = properties.get('--progress-color').toString();
ctx.lineCap ='round';
ctx.beginPath();
ctx.arc(cx, cy, r,-Math.PI/2,(-Math.PI/2) + (progress *Math.PI*2));
ctx.stroke();}}registerPaint('circle-progress', CircleProgressPainter);/* Usage — the argument is passed directly in paint() */.progress{--progress-color:oklch(0.55 0.22 248);background:paint(circle-progress,75);/* 75% progress */}
Paint worklet limitations. The PaintRenderingContext2D
has no access to DOM, cookies, the network, or any global state. It cannot use
requestAnimationFrame — animations must be driven by
@keyframes or custom property transitions on the host element.
The worklet may be called on any thread at any time. Write it as a pure function
of its inputs.
Fallback pattern
/* Always provide a plain CSS fallback before the paint() value *//* Browsers that don't support the Paint API ignore paint() and use the first value */.card{background:oklch(0.15 0.02 248);/* fallback */background:paint(noise-background);/* progressive enhancement */}/* Or use @supports */@supports(background:paint(x)) {.card{background:paint(noise-background);}}/* Feature detection in JavaScript */if('paintWorklet'in CSS) {
CSS.paintWorklet.addModule('/worklets/noise.js');
document.documentElement.classList.add('paint-api');}.paint-api .card{background:paint(noise-background);}
3. The CSS Typed Object Model (Typed OM)
The traditional element.style API deals in raw strings.
Typed OM replaces this with typed JavaScript objects — CSSUnitValue,
CSSMathSum, CSSKeywordValue — that let you read, modify,
and do arithmetic on CSS values without string parsing.
Old CSSOM — strings, fragile
/* Read */const w = el.style.width;// "120px" — a string/* Arithmetic — must parse manually */const num =parseFloat(w);
el.style.width =(num +10) +'px';/* Computed style — also strings */const cs =getComputedStyle(el);
cs.getPropertyValue('margin-top');// "16px" — parse required
Typed OM — objects, safe
/* Read — get a typed value */const w = el.attributeStyleMap.get('width');// CSSUnitValue {value: 120, unit: 'px'}/* Arithmetic — direct, typed */
w.value +=10;
el.attributeStyleMap.set('width', w);/* Computed style — typed */const cs = el.computedStyleMap();
cs.get('margin-top');// CSSUnitValue {value: 16, unit: 'px'}
/* Inside a paint worklet, properties.get() returns Typed OM values *//* This is safer than parsing strings manually */class RipplePainter {staticget inputProperties() {return['--ripple-color','--ripple-radius'];}paint(ctx, size, props) {/* --ripple-color is a registered <color> — comes back as CSSStyleValue */const color = props.get('--ripple-color').toString();/* --ripple-radius is a registered <number> — comes back as CSSUnitValue */const radius = props.get('--ripple-radius').value;/* numeric, no parsing */
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(size.width /2, size.height /2, radius,0,Math.PI*2);
ctx.fill();}}registerPaint('ripple', RipplePainter);/* The CSS for the worklet */@property--ripple-radius{syntax:'<number>';initial-value:0;inherits:false;}.btn{--ripple-radius:0;--ripple-color:oklch(1 0 0 / 0.2);background:paint(ripple);transition:--ripple-radius0.6s ease-out;}.btn:active{--ripple-radius:200;}/* @property makes the radius animatable — the ripple grows smoothly *//* Paint API redraws on every animation frame as --ripple-radius changes */
5. Browser Support Reference
API / Feature
Chrome
Edge
Firefox
Safari
Notes
@property
105+
105+
128+
16.4+
Baseline 2024 — use in production
CSS.registerProperty()
78+
79+
128+
16.4+
Prefer @property — declarative and collocated
CSS.paintWorklet / Paint API
65+
79+
No
No
Chrome/Edge only. Always provide a CSS fallback
element.attributeStyleMap
66+
79+
No
No
Chrome/Edge only. Graceful degrade to element.style
element.computedStyleMap()
66+
79+
No
No
Chrome/Edge only. Degrade to getComputedStyle()
CSS.px(), CSS.em() etc.
66+
79+
No
No
Factory functions — Chrome/Edge only
Layout Worklet
Flag
No
No
No
Experimental only — do not ship
Animation Worklet
Flag
No
No
No
Partially superseded by Scroll-Driven Animations
Chapter Summary
Concept
Key point
@property
Registers a custom property with a type (syntax), initial value, and inheritance flag. Enables CSS transitions and animations on custom properties used inside gradients, transforms, and clip-paths — which the browser cannot interpolate without type information. Baseline 2024 — use in production today.
syntax values
Common types: <length>, <number>, <percentage>, <color>, <angle>, <transform-list>. Combinators: + for space-separated list, # for comma-separated, | for keyword union. * means "any" — same as unregistered.
Paint API
Write a JavaScript class extending PaintRenderingContext2D, register it with CSS.paintWorklet.addModule(), then use paint(name) anywhere CSS expects an image. inputProperties declares which custom properties the worklet reads. inputArguments lets you pass values directly in paint(). Chrome/Edge only — always provide a CSS fallback.
Worklet isolation
Paint worklets have no DOM, network, or global state access. They may run on any thread. The worklet receives: ctx (canvas-like context), size (width/height of the element), properties (Typed OM values for inputProperties). Write as a pure function of its inputs.
Typed OM
element.attributeStyleMap and element.computedStyleMap() replace element.style and getComputedStyle() with typed objects. CSS.px(42) returns CSSUnitValue {value: 42, unit: 'px'} — arithmetic without parsing. Chrome/Edge only — degrade gracefully with feature detection.
@property + Paint API
The two Houdini APIs work together: register a custom property with @property so it can be animated, then read it in a paint worklet. The worklet redraws on every animation frame as the property value changes. This pattern enables CSS-driven animated custom backgrounds.
Support strategy
@property — ship everywhere, Baseline 2024. Paint API — Chrome/Edge progressive enhancement, always provide a plain CSS fallback. Typed OM — Chrome/Edge tooling enhancement, degrade to string-based CSSOM. Feature-detect with 'paintWorklet' in CSS or 'attributeStyleMap' in element.
Exercises
Animated gradient angle: Register a --gradient-angle custom property with syntax: '<angle>'. Apply it to a conic-gradient() background. Write a @keyframes rule that animates --gradient-angle from 0deg to 360deg over 6 seconds. Confirm the gradient spins smoothly. Then remove the @property block and observe that the gradient jumps instead of spinning — this demonstrates exactly why type registration is necessary.
Spotlight button: Register --spotlight-x and --spotlight-y as <percentage> properties. Apply a radial-gradient(circle at var(--spotlight-x) var(--spotlight-y), ...) to a button's background. Use a mousemove listener and attributeStyleMap.set() (with a string fallback for Firefox/Safari) to update both properties to the cursor's position within the button. The result: a spotlight that follows the cursor, with smooth interpolation on quick moves.
Checkerboard paint worklet: Write a complete paint worklet file called checkerboard.js. The worklet should read --cb-cell-size (an integer, default 20) and --cb-color-a, --cb-color-b from inputProperties. Draw the checkerboard pattern using ctx.fillRect. Register it from a script, provide a solid-color fallback in CSS, and use @supports (background: paint(x)) to enable the worklet background. Apply it to a full-page div and verify it tiles correctly when you resize the browser.
Progress ring with @property + Paint API: Register --ring-progress as a <number> with initial value 0. Write a circle-progress paint worklet that draws an arc from -90° based on the progress value (0–100). Wire up a button that transitions --ring-progress from 0 to 75. Because the property is registered, the arc should animate smoothly through each intermediate value. Add a second button that resets it back to 0.
Typed OM arithmetic helper: Write a utility function addToProperty(el, prop, delta) that: (1) reads the current value of prop from el.computedStyleMap() if available, else parses getComputedStyle(el).getPropertyValue(prop); (2) adds delta (in the same unit) to the value; (3) writes it back via el.attributeStyleMap.set() if available, else el.style.setProperty(). Test it by repeatedly calling addToProperty(box, 'width', 10) on click — the box should grow by 10px each click in all browsers, using the typed path in Chrome and the fallback path in Firefox/Safari.
Next: Chapter 9 — CSS Performance.
How browsers paint and composite layers; what triggers repaints vs reflows;
the compositor thread and why transform and opacity
are fast; will-change and when it helps vs hurts;
contain for layout and paint isolation;
content-visibility: auto for offscreen content;
@layer and the cascade engine; and DevTools workflows for
diagnosing and eliminating jank.