CSS Houdini

Chapter 8 — CSS Houdini: Painting and Typed OM

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 flag  Firefox —  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 only  Firefox —  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-pos 0.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; } @keyframes spin-gradient { to { --gradient-angle: 360deg; } } .spinning-card { background: conic-gradient(from var(--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-color 0.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-y 0.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 { static get 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 { static get inputProperties() { return ['--progress-color']; } static get 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'}
/* ── CSS factory functions ───────────────────────────────────── */ /* Create typed values from JavaScript */ CSS.px(42) // CSSUnitValue {value: 42, unit: 'px'} CSS.em(1.5) // CSSUnitValue {value: 1.5, unit: 'em'} CSS.rem(2) // CSSUnitValue {value: 2, unit: 'rem'} CSS.percent(50) // CSSUnitValue {value: 50, unit: 'percent'} CSS.vw(100) // CSSUnitValue {value: 100, unit: 'vw'} CSS.deg(45) // CSSUnitValue {value: 45, unit: 'deg'} CSS.s(0.3) // CSSUnitValue {value: 0.3, unit: 's'} CSS.number(1.5) // CSSUnitValue {value: 1.5, unit: 'number'} /* Math types */ new CSSMathSum(CSS.px(100), CSS.percent(50)) // calc(100px + 50%) new CSSMathProduct(CSS.px(10), CSS.number(3)) // calc(10px * 3) new CSSMathMax(CSS.px(48), CSS.rem(3)) // max(48px, 3rem) /* ── Writing values ──────────────────────────────────────────── */ el.attributeStyleMap.set('width', CSS.px(200)); el.attributeStyleMap.set('opacity', CSS.number(0.5)); el.attributeStyleMap.set('display', new CSSKeywordValue('flex')); el.attributeStyleMap.delete('margin-top'); /* ── Reading computed values ─────────────────────────────────── */ const map = el.computedStyleMap(); const mt = map.get('margin-top'); mt.value; // 16 mt.unit; // 'px' /* Reading shorthand — returns CSSStyleValue array */ const padding = map.getAll('padding'); // [top, right, bottom, left] /* Unit conversion */ const inPx = CSS.em(2).to('px'); // resolves relative em to absolute px value in the document context /* ── Practical: animate element width with typed arithmetic ─── */ function expandBy(el, amount) { const current = el.computedStyleMap().get('width'); el.attributeStyleMap.set('width', CSS.px(current.value + amount)); /* No string parsing, no unit stripping, no NaN risk */ }

4. Typed OM inside Worklets

/* Inside a paint worklet, properties.get() returns Typed OM values */ /* This is safer than parsing strings manually */ class RipplePainter { static get 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-radius 0.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

ConceptKey point
@propertyRegisters 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 valuesCommon 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 APIWrite 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 isolationPaint 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 OMelement.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 APIThe 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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.