Step 1
Install
Install DOM Layout Shim alongside your DOM implementation. The engine supports Node.js 22 and newer.
# Install both packages as test-only dependencies.
pnpm add -D dom-layout-shim happy-dom
The published package includes its Taffy WebAssembly module. Package consumers
do not need Rust or wasm-pack; those tools are required only when building
DOM Layout Shim from source.
Step 2
Attach the engine
The returned handle has the public type LayoutEngine. TypeScript consumers
should replace imports of LayoutEngineAttachment with LayoutEngine:
import { attachLayoutEngine, type LayoutEngine } from 'dom-layout-shim'
const layoutEngine: LayoutEngine = await attachLayoutEngine({ window })
layoutEngine.detach()
Create the window normally, populate the document, then attach once. Set an explicit viewport when tests depend on available width or height.
import { Window } from 'happy-dom'
import { attachLayoutEngine } from 'dom-layout-shim'
const window = new Window()
// Populate the document before attaching the engine.
window.document.body.innerHTML = `
<button id="save" style="width:120px; height:40px">Save</button>
`
// Use an explicit viewport whenever available space affects the assertion.
await attachLayoutEngine({
window,
viewport: { width: 800, height: 600 },
})
Geometry is recomputed after DOM, class, inline-style, stylesheet, CSSOM, and scroll changes. Repeated reads use the cached snapshot; scroll-only changes reuse computed layout.
Inline attributes and stylesheets share CSS declaration parsing and cascade
priority. A stylesheet width: 100px !important beats normal inline
width: 200px; an important inline declaration beats important author rules.
Font-relative dimensions resolve after font size: both width: 2em; font-size: 30px and the reversed declaration order produce a 60px width. Generated
::before and ::after content also resolves custom properties through this
cascade, including variables declared on the pseudo-element itself.
Portable presentation defaults and HTML sizing hints also enter this cascade.
Normal user-agent rules yield to HTML hints such as <img width="100">, and
normal author CSS can override those hints. Important user-agent rules retain
priority over important author rules. A paragraph with font-size: 2em inside
a 30px parent now resolves to 60px, rather than using the portable paragraph
font size as its inheritance base.
For non-replaced text containers, authored display: inline uses the same
wrapped fragments as native phrasing elements. For example,
<div style="display:inline">one two three</div> participates in its parent's
line layout instead of creating a block. Absolute/fixed elements and flex/grid
items are blockified before layout. Atomic inline replaced elements,
inline-block, and inline outer flex/grid/table layout remain outside this
inline-formatting subset.
Table properties also use normal inheritance. A caption with caption-side: top
overrides a table's caption-side: bottom; empty-cells can inherit through
row groups and rows before a cell's own declaration overrides it.
Step 3
Use document stylesheets
Repeated geometry reads reuse cached layout without re-serializing unchanged
stylesheet rules. CSSOM methods and declaration setters invalidate the affected
sheet, including edits such as rule.style.width = "120px" that keep the same
rule count. Hosts whose CSSOM cannot be patched retain content fingerprinting.
The engine reads document <style> elements, accessible linked stylesheets,
and constructable stylesheets in document.adoptedStyleSheets. Document sheets
follow DOM order, and adopted sheets follow them in adoption order, matching
the browser cascade.
const layoutSheet = new window.CSSStyleSheet()
layoutSheet.replaceSync('.dialog { position:fixed; inset:0 }')
window.document.adoptedStyleSheets = [layoutSheet]
await attachLayoutEngine({ window })
// CSSOM changes and adopted-sheet reordering invalidate cached geometry.
layoutSheet.replaceSync('.dialog { position:fixed; inset:20px }')
dialog.getBoundingClientRect()
The engine reads external rules only when the DOM implementation exposes their
cssRules. Cross-origin and otherwise inaccessible linked sheets are reported
through unsupportedCss: the default policy warns and continues, while strict
mode throws rather than silently omitting the sheet.
Step 4
Configure user-agent styles
The portable profile is the default deterministic presentation baseline for
unstyled headings, paragraphs, lists, dialogs, and controls. It does not inspect
the host browser or operating system. Configure the user-agent origin in one
place when an application uses a reset or needs a different baseline:
await attachLayoutEngine({
window,
userAgentStyles: {
profile: 'portable',
// These rules remain below application styles in the cascade.
overrides: 'p { margin: 0 } button { font: inherit }',
},
})
Set profile: 'none' to remove portable presentation defaults while retaining
the override CSS. Structural behavior is independent: hidden inputs still do
not generate boxes. Native-control intrinsic geometry also remains independent
under nativeControls.
html and body currently form the engine's synthetic viewport containing
block rather than independent boxes. Their own margins, padding, and geometry
are therefore not yet modeled by profile overrides.
Custom properties inherit and cascade before supported layout declarations are
parsed. Local values override inherited values, and fallbacks can contain other
var() references:
window.document.body.innerHTML = `
<main style="--panel-width: 320px">
<section id="panel" style="width:var(--panel-width); gap:var(--gap, 8px)"></section>
</main>
`
await attachLayoutEngine({ window })
// 320: inherited from <main>; --gap uses its 8px fallback.
panel.getBoundingClientRect().width
Missing and cyclic references use their declaration fallback when present. An
unresolved supported declaration without a fallback is reported through
unsupportedCss.
Responsive @media rules use the viewport passed to attachLayoutEngine, not
the DOM host's window dimensions:
const layoutSheet = new window.CSSStyleSheet()
layoutSheet.replaceSync(`
.sidebar { width: 240px }
@media (max-width: 600px) { .sidebar { width: 100px } }
`)
window.document.adoptedStyleSheets = [layoutSheet]
await attachLayoutEngine({ window, viewport: { width: 480, height: 800 } })
// 100: the narrow responsive branch matches the configured viewport.
sidebar.getBoundingClientRect().width
Media types, width and height ranges, orientation, aspect ratio, query lists,
conjunctions, and nested media rules share the matchMedia() evaluator.
Unsupported media features are reported through unsupportedCss.
Keep shared defaults in test setup and override only responsive scenarios:
const layoutEngine = await attachLayoutEngine({ window })
// Recompute against a phone-sized viewport without remounting the application.
layoutEngine.setViewport({ width: 390, height: 844 })
setViewport() updates shim-backed window.innerWidth and window.innerHeight,
invalidates cached geometry, updates subsequent matchMedia() answers, and
dispatches window.resize.
Assigning window.innerWidth or window.innerHeight while attached throws a
TypeError that points to layoutEngine.setViewport({ width, height }). For
example, replace window.innerWidth = 320 with
layoutEngine.setViewport({ width: 320, height: 640 }). This also applies in
non-strict scripts, where an assignment previously could silently do nothing.
Step 5
Observe element resizing
Resize observations and inline fragments use the padding resolved during layout.
For example, a content-box block with width:100px;height:80px;padding:10%;border: 2px solid inside a 200px-wide parent has 20px padding on every side: its border
box is 144×124px, and its observed content box is 100×80px. Grid items resolve
percentage padding against their grid area. Changing a containing block's size
recomputes the padding before sizing parents and following siblings.
The attached window provides a layout-backed ResizeObserver. The engine
retains lazy layout while there are no active observation targets, then batches
observed changes automatically:
const observer = new window.ResizeObserver(([entry]) => {
console.log(entry.borderBoxSize[0].inlineSize)
})
observer.observe(panel, { box: 'border-box' })
For tests that need an explicit synchronization point, disable automatic observer delivery and flush after making changes:
const layoutEngine = await attachLayoutEngine({
window,
observers: { delivery: 'manual' },
})
panel.style.width = '320px'
layoutEngine.flushLayout()
flushLayout() recomputes dirty geometry and synchronously settles pending
layout-backed observer callbacks. Reading geometry still computes lazily but
does not implicitly deliver observer callbacks.
Step 6
Observe intersections
The attached window also provides a layout-backed IntersectionObserver.
Viewport and element roots, rootMargin values in pixels or percentages, and
threshold arrays are supported:
const observer = new window.IntersectionObserver(([entry]) => {
console.log(entry.isIntersecting, entry.intersectionRatio)
}, {
root: scroller,
rootMargin: '0px 16px',
threshold: [0, 0.5, 1],
})
observer.observe(card)
Automatic delivery reacts to layout mutations, viewport changes, and scrolling.
With observers.delivery: 'manual', flushLayout() settles resize observations
first and then reports intersections against the resulting geometry.
Step 7
Configure native controls
Unstyled controls use the cross-host portable profile by default. Select it
explicitly to make the test target clear, then override only the control metrics
your environment needs.
// Keep the portable defaults except for controls your harness customizes.
await attachLayoutEngine({
window,
nativeControls: {
profile: 'portable',
overrides: {
textInput: { width: 220 },
checkboxRadio: { width: 16, height: 16 },
},
},
})
The text input is now 220 pixels wide while retaining the profile's 23-pixel height. Overrides merge by control group and metric; replacing every field in every group defines a fully custom profile. Profiles model outer geometry, not operating-system painting or internal widget behavior.
Step 8
Read layout-backed geometry
Bounding rectangles, offsets, client and scroll dimensions, offset parents, scrolling, and supported transforms come from one snapshot.
// All of these values come from the same cached layout snapshot.
const save = window.document.querySelector('#save')!
const rect = save.getBoundingClientRect()
console.log(rect.left, rect.top, rect.width, rect.height)
console.log(save.offsetTop, save.offsetLeft, save.offsetParent)
save.scrollIntoView({ block: 'center', inline: 'nearest' })
scrollWidth and scrollHeight report the padding-box size or its scrollable
content extent, whichever is larger, rounded to integer CSS pixels. For example,
a 100×60 container with overflow:auto and a 240×180 child reports scroll sizes
of 240×180 instead of zero. Borders are excluded; nested clipped overflow does
not enlarge the outer container, and scrolling does not shrink the reported
size. The engine's existing synthetic html/body, inline-display, native-control,
and transform-containing-block limitations still apply.
Repeated reads reuse the layout snapshot and its hit-test ordering. Scroll-only
changes reuse computed layout and update viewport geometry, sticky positioning,
and clipping. Editing one stylesheet preserves the parsed data for other sheets;
viewport changes re-evaluate media queries without reparsing unchanged CSS.
Inline declaration parsing, selector expansion, and built-in text measurements
use adaptive bounded caches: recently evicted inputs being reused trigger growth
from 512 up to 4,096 entries; unique inputs alone do not. Active layout passes
retain their selector expansions even
when a stylesheet exceeds the shared cache, avoiding repeated parsing for
large CSS-in-JS stylesheets. Injected textMeasurer implementations are not memoized;
scroll-only reads reuse the layout already computed from their measurements.
DOM mutations are checked synchronously when geometry is read. Hosts with
non-patchable CSSOM or scroll APIs retain conservative validation paths.
For example, a cached read after scrolling updates the rectangle without repeating text measurement or flow layout:
const panel = window.document.querySelector<HTMLElement>('#panel')!
const row = panel.querySelector('.row')!
const before = row.getBoundingClientRect()
panel.scrollTop += 20
const after = row.getBoundingClientRect()
// An ordinary row moves up by the actual scroll delta; its size stays the same.
Intrinsic sizing accepts min-content, max-content, and fit-content for
width, height, inline-size, and block-size. For example,
style="width: max-content" sizes a text box to its unwrapped content instead
of filling the parent; width: fit-content clamps the available width between
the minimum and maximum content widths. Intrinsic keywords in min/max dimension
constraints remain unsupported.
Grid rows and columns accept auto, min-content, max-content, and
fit-content(<px-or-percentage>) in explicit, implicit, and integer-repeat
tracks. For example, style="display: grid; grid-auto-flow: column; grid-auto-columns: max-content" gives each implicit column its own content
width instead of splitting the container evenly.
Step 9
Place named grid areas
Rectangular grid-template-areas definitions place children whose grid-area
names match the template. Areas may span rows and columns, and . leaves an
unnamed cell. These templates and placements are passed directly to Taffy's
native named-area model:
window.document.body.innerHTML = `
<main style='display:grid; grid-template-columns:80px 120px;
grid-template-areas:"nav content"'>
<nav style="grid-area:nav"></nav>
<article id="content" style="grid-area:content"></article>
</main>
`
await attachLayoutEngine({ window })
// The article begins after the 80px navigation track.
content.getBoundingClientRect().left
Named grid lines and escaped area identifiers remain unsupported.
Step 10
Test sticky UI
position: sticky uses physical top, right, bottom, and left insets
against the nearest supported scrolling ancestor, or against the configured
viewport when no such ancestor exists. Sticky boxes remain in normal flow,
move their descendants and hit targets together, and stop at the edge of their
containing block. Table header groups and cells use the same behavior.
window.document.body.innerHTML = `
<div style="height:80px; overflow:auto">
<header id="toolbar" style="position:sticky; top:0; height:30px"></header>
<main style="height:300px"></main>
</div>
`
await attachLayoutEngine({ window })
// The toolbar remains at the scrollport top after its container scrolls.
toolbar.getBoundingClientRect().top
Step 11
Test pointer targets
Point queries respect layout, stacking order, visibility, pointer events, clipping, scrolling, and supported transforms.
// Query the element's visual center to verify that it receives the pointer.
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
expect(window.document.elementFromPoint(centerX, centerY)).toBe(save)
expect(window.document.elementsFromPoint(centerX, centerY)).toContain(save)
Step 12
Handle unsupported CSS
Layout declarations can use em, rem, viewport units, custom properties, and
calc() expressions when the result reduces to one supported length,
percentage, or number. Mixed percentage-and-pixel dimensions such as
calc(100% - 32px) resolve when their containing-block axis is definite.
Calculated dimensions also resolve for generated boxes and descendants of table
cells after their containing widths are allocated. For example, a 200px cell
containing a child with width: calc(100% - 20px); aspect-ratio: 2 gives that
child a 180px width and a 90px height; the row includes that resulting height.
Percentage insets resolve against the corresponding definite containing-block
axis. Nested positioned stacking contexts keep descendant z-index values
inside the ancestor context during point queries.
The exported HitBox type exposes this optional nested paint key as
stackingOrder for diagnostic consumers.
Two-dimensional translation, scaling, rotation, skew, and matrix transforms project client rectangles and hit-test regions. Rotated and skewed elements use their transformed quadrilateral for point queries.
Overflow clips remain attached to their ancestors when descendants transform.
For example, a 100px-wide child translated 80px right inside a 100px-wide
overflow: hidden parent is hittable only in the visible 20px strip.
Intersection observations use the same projected ancestor clips.
Inline text, including bare text after a block child, shares styled line layout
with generated content. For example, a 30px-tall block followed by Hello in a
container with line-height: 20px contributes a total height of 50px. Nested
inline font settings and pseudo-element typography affect measurement; inline
client fragments also participate in point queries. pre-wrap and pre-line
use the shared wrapping rules, including preserved hard-break fragments.
Table cells lay out block, flex, grid, and inline descendants through the shared
formatting pipeline. A width: 100px; height: 40px div inside an otherwise empty
cell now reports 100×40px rather than a zero rectangle. Allocated cell widths
reflow text; vertical-align: top, middle, and bottom place cell contents.
Full intrinsic table track distribution and collapsed-border conflict resolution
remain outside the supported table subset.
Calculated dimensions use one containing-block resolver before and after layout,
including percentage ancestors and border-box padding. For example, a child
with width: calc(100% - 20px) inside a 200px border box with 10px padding on
each side and 5px borders has width 150px. Absolute descendants use their
positioned containing block across intervening static ancestors. In standards
mode, observed auto height does not establish a definite percentage-height basis.
For non-replaced inline elements, offsetWidth and offsetHeight span the
fragment union, while offsetLeft and offsetTop use the first fragment.
clientWidth and clientHeight remain zero. Positioned overlays paint above
ordinary inline text, and in-flow descendants paint above their positioned
container's background.
Images use host-provided naturalWidth and naturalHeight when available.
Image width/height attributes provide sizing hints and a fallback ratio; loaded
image dimensions take precedence for the natural ratio. SVG uses numeric
width/height attributes or a valid viewBox for its intrinsic ratio. Canvas
uses its width/height attributes, defaulting to 300×150.
These ratios determine the automatic dimension when CSS specifies only one axis, including supported min/max constraints and flex/grid placement:
<svg viewBox="0 0 200 100" style="width: 100px; height: auto"></svg>
<!-- getBoundingClientRect() reports 100 × 50 after attaching the layout engine. -->
An authored numeric aspect-ratio overrides the natural ratio. Image load and
error events invalidate cached geometry. Resource loading and decoding remain
with the DOM host; the shim does not fetch images. SVG child shapes and canvas
pixels are not rendered or given shape-specific hit regions. SVG attribute
lengths beyond unitless numbers and pixels, and natural-ratio border-box sizing
with unresolved percentage constraints, remain outside this supported subset.
Custom text measurers receive resolved numeric fontWeight, letterSpacing,
and wordSpacing values so component typography can influence intrinsic
geometry. Inherited word-spacing accepts normal and supported lengths,
including negative values. For example, word-spacing: 4px adds four pixels
to each remaining space in measured text, increasing intrinsic width and
potentially moving words onto another line. Spaces and no-break spaces are
covered; script-specific word separators are not modeled.
They also receive text after inherited none, uppercase, lowercase, or
capitalize transformation. The DOM's authored textContent is unchanged.
Without a custom measurer, the layout engine discovers initial @font-face rules and
loads static TTF, OTF, and WOFF URL or data sources. It selects the closest
discovered numeric weight in the authored family list and measures glyph
advances and kerning directly from that font. Unavailable families, local()
sources, and WOFF2 sources use deterministic fallback measurement.
Supported selectors include structural and state pseudo-classes, :is(),
:where(), :not(), :has(), and case-insensitive terminal HTML attribute
selectors. String and attr() content generated by ::before and ::after
contributes to intrinsic text layout. A generated pseudo-element with a
non-inline display contributes its own anonymous box, dimensions, spacing,
and flex or grid item placement. For example, ::before { content: ""; display: block; height: 12px; margin-bottom: 3px } reserves 15px before the
originating element's ordinary content.
Native CSS nesting is supported for supported selectors and declarations,
including &, implicit descendants, child combinators, and nested viewport
@media rules. Parent selector lists retain their highest specificity and
mixed declarations retain source order. For example,
.card { .item { width: 60px; } } gives an .item inside .card a
60px width through getBoundingClientRect().
The default policy warns and continues. Use strict mode when a silent difference would make a test misleading.
// Fail fast unless a known visual-only declaration is deliberately ignored.
await attachLayoutEngine({
window,
unsupportedCss: {
default: 'throw',
overrides: [{ property: 'filter', decision: 'ignore' }],
},
})
Check the CSS support explorer for exact syntax, behavior-specific support claims, Chromium fixtures, and limitations. Select a fixture to preview its test source without leaving the explorer, or follow its GitHub link to inspect the repository version.
Stylesheet parser recovery also consults the unsupported-CSS policy. For example,
stylesheets: ['div: { width: 20px }'] reports an unsupported-rule entry with
property stylesheet and the original CSS when layout is queried, instead of
silently discarding the rule. Strict policy errors and warning callback errors
propagate unchanged.
Use unsupportedCss: { reporter } to collect warnings directly. The default is
warn; explicit default, properties, and property decisions still take
precedence. An optional onWarning callback receives the same warnings. Layout
is lazy: query geometry before reading the summary. Values contain CSS text
(for example, animation-delay: 0.4s reports value 0.4s), and unsupported
selectors contain selector text rather than an AST dump. Unsupported rules and
parser recovery include the authored stylesheet; unavailable stylesheet entries
retain their diagnostic description.
Merge summaries from isolated test workers or windows with the public
mergeUnsupportedCssSummaries(summaries) helper:
import { mergeUnsupportedCssSummaries } from 'dom-layout-shim'
const combined = mergeUnsupportedCssSummaries([firstSummary, secondSummary])
console.log(combined.unsupportedDeclarationCount)
Transport each worker's reporter.getSummary() as JSON using your test runner's
collection mechanism. Merging combines equal property/value/reason entries,
sums occurrences, and sorts and deduplicates their metadata without mutating
inputs. Warning deduplication still happens per layout engine, so occurrences count
collected warnings rather than every element or layout query. An empty input
produces an empty summary.
Step 13
Explore UI-library examples
The UI library examples implement the same task workspace in Material UI and Ant Design. Their tests attach DOM Layout Shim to happy-dom and exercise geometry-derived pointer targets, scrollable content, portalled menus, modal blocking, and layout invalidation through real library components.
The hosted pages run in a browser for visual inspection. They complement rather than replace Chromium parity fixtures: each example publishes its known compatibility limitations alongside the working scenario.
Run pnpm run examples:compatibility to execute every named checkpoint in both
Chromium and happy-dom with the shim. The command reports observation coverage,
agreement by geometry, visibility, and hit testing, repeated difference groups,
stability, computed layout inputs, hit-test stacks, and unsupported CSS observed
on the example elements. An ordinary difference does not fail the command; only
failure to execute or capture the report does.
The generated examples/*/compatibility-report.json files are ignored build
artifacts. Run the command before local documentation generation when fresh
reports are needed. Documentation CI always regenerates them with its installed
Chromium before assembling the deployment artifact.
Step 14
Use it in a test lifecycle
Attach after creating the window, detach after the test, and close the window when its resources are no longer needed.
let layoutEngine: Awaited<ReturnType<typeof attachLayoutEngine>>
// Give each test an isolated document and layout engine.
beforeEach(async () => {
window = new Window()
layoutEngine = await attachLayoutEngine({ window })
})
// Release layout patches and DOM resources after every test.
afterEach(() => {
layoutEngine.detach()
window.close()
})
Use isLayoutEngineAttached(window) to check whether a layout engine is attached and
layoutEngine.detach() to return the window to its DOM harness:
import { attachLayoutEngine, isLayoutEngineAttached } from 'dom-layout-shim'
if (!isLayoutEngineAttached(window)) {
const layoutEngine = await attachLayoutEngine({ window })
// Run the test using deterministic geometry.
layoutEngine.detach()
console.log(isLayoutEngineAttached(window)) // false
}
Detach restores original property descriptors for geometry, hit testing,
scrolling, viewport dimensions, matchMedia, observer constructors, and CSSOM
tracking. It disconnects internal mutation observers, removes event listeners,
cancels pending observer delivery, and clears layout-backed observations and
caches. Shared prototype hooks remain available to other attached windows;
detached elements use their native behavior. Calling detach() repeatedly is
safe. setViewport() and flushLayout() on the detached layout engine throw.
Attaching again replaces the previous layout engine; calling the old layout engine's
detach() cannot disconnect its replacement.