Agents: read this page as Markdown or the agent guide and documentation index.

Deterministic browser geometry

Layout and hit testing for DOM test harnesses.

For agents, start with the agent guide and text documentation index. Every documentation page links to a static Markdown alternative; CSS support has a compact index with separate topic files.
TypeScript
import { attachLayoutEngine } from 'dom-layout-shim'

// Attach once so DOM geometry APIs read from the deterministic layout snapshot.
await attachLayoutEngine({ window })

// Geometry and point queries now share the same computed layout.
const rect = button.getBoundingClientRect()
const target = document.elementFromPoint(
  rect.x + rect.width / 2,
  rect.y + rect.height / 2,
)

Step 1

Install

Install DOM Layout Shim alongside your DOM implementation. The engine supports Node.js 22 and newer.

Shell
# 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:

TypeScript
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.

TypeScript
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.

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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.

TypeScript
// 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.

TypeScript
// 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:

TypeScript
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:

TypeScript
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.

TypeScript
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.

TypeScript
// 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:

HTML
<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.

TypeScript
// 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:

TypeScript
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.

TypeScript
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:

TypeScript
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.