Documentation

How Boxy detects layout bugs, what it catches, what it ignores, and how to configure it.

Overview
Pixel Diff
??? before after
"Something changed in this region"
No idea what or why
Boxy — Spatial Model
overflow: hidden dropdown clipped 30px 80px
"Dropdown clipped by parent
overflow: hidden — bottom: 30px"
Key insight: Layout bugs are structural, not visual. You don't need to compare pixels to know a dropdown is clipped — you just need to know its bounding box extends beyond a parent with overflow: hidden. That's a math problem, not a vision problem.

For every visible element, Boxy records a JSON model with these fields:

[data-testid="menu"] <div> · position: absolute width: 200px height: 120px SPATIAL MODEL box {x:420, y:280, w:200, h:120} position absolute zIndex 1000 overflow visible clip isClipped: true clippedBy .table-area edges bottom: 94px visibility visible opacity 1 spacing prevGap: 16px styles {display, overflow, ...}

This is pure data — no screenshots needed. The linter and regression engine work entirely on this JSON model, making analysis deterministic and environment-independent.

boxy.capture(page) Walk DOM → spatial model lint(model) → issues baseline exists? NO Auto-save baseline Save .json + .png Return lint issues only YES compare() baseline vs current Return lint + regression + CSS diff + screenshots Screenshots saved for HTML report comparison

On the first run, baselines are auto-saved. On subsequent runs, Boxy compares against the saved baseline and reports regressions with CSS diffs. The HTML report shows baseline vs current screenshots side-by-side so you can visually confirm the detected issues.

Update flow: After intentional layout changes, run with LAYOUT_UPDATE=true to overwrite baselines. For initial setup, use LAYOUT_INIT=true to accept new baselines without failure.
● Static Lint

No baseline needed. Analyses the current page and detects broken patterns from geometry alone:

  • Clipping — dropdown extends beyond overflow:hidden parent
  • Overlap — high z-index element clipped while overlapping lower
  • Collapsed — near-zero dimensions with visible children
  • Off-screen — positioned element fully outside viewport
● Regression Compare

Baseline vs current. Catches unintended side-effects of CSS changes:

  • Position — element shifted beyond threshold
  • Size — width/height changed >30%
  • Visibility — disappeared, hidden, or transparent
  • Spacing — sibling gaps changed or became inconsistent
  • Broken scroll — overflow changed from scrollable to hidden

When a regression is detected, Boxy can optionally run causation analysis — re-rendering the page with each CSS change applied in isolation to prove exactly which property caused the breakage.

Detection Checks

Detects absolute or fixed positioned elements that extend beyond a parent with overflow: hidden, auto, scroll, or clip.

This catches the most common layout bug: dropdowns, popovers, tooltips, and menus clipped by an ancestor container.

What triggers it:

  • Element has position: absolute or position: fixed
  • An ancestor has overflow that clips
  • The element's bounding box extends beyond that ancestor
  • Any amount of clipping counts (even 1px)

What does NOT trigger:

  • position: static or relative elements — assumed intentional (text truncation, image crops, carousels)
  • Elements that fit within their parent bounds (no overflow)
  • Elements suppressed by intent signals (visibility:hidden, opacity:0)
Nested clipping: If a parent dropdown is clipped and its child tooltip is also clipped by the same ancestor, Boxy reports one issue on the parent with affectedChildren listing the nested elements.

Flags elements with width or height between 1px and collapsedMinSize (default: 5px) that have visible content and child elements.

Conditions:

  • width > 0 && width < collapsedMinSize (or same for height)
  • hasVisibleContent: true
  • childCount > 0
Gap: Elements at exactly width: 0 are not flagged (the check requires width > 0). Zero-dimension elements are normally filtered at capture time. Text-only elements without child elements (childCount: 0) are also skipped. Both cases need regression mode (SIZE check) to detect.

Flags elements that are entirely outside the viewport bounds.

Conditions:

  • Element is fully off-screen (no part visible in viewport)
  • position is not static
  • hasVisibleContent: true
  • Not intentionally hidden (sr-only pattern, visibility:hidden, etc.)
Gap: Partially off-screen elements are not flagged. A dropdown hanging 80% off the viewport edge would be missed. Only fully off-screen elements trigger this check.

Detects two positioned elements (both with z-index > 0) that overlap spatially, where the higher z-index element is clipped by an ancestor's overflow.

This catches stacking context traps: a tooltip with z-index: 9999 that's clipped because a parent has opacity: 0.99 (creating a new stacking context).

Conditions:

  • Both elements have z-index > 0
  • Their bounding boxes overlap
  • The higher z-index element has clip.isClipped: true
Gap: Elements with z-index: 0 (the default for most positioned elements) are never checked for overlap.

Detects containers whose overflow changed from scrollable (auto/scroll) to non-scrollable (hidden/clip) while content still exceeds the visible area.

Why regression only: A single snapshot of overflow: hidden with scrollHeight > height is ambiguous — it could be a carousel, slider, or masked layout. But overflow: auto → hidden with content still overflowing is definitive: content that was reachable is now inaccessible.

Conditions:

  • Overflow changed from auto/scroll to hidden/clip
  • scrollHeight > height (or scrollWidth > width)
  • Checks per-axis: overflowX and overflowY independently

What does NOT trigger:

  • hidden → hidden (always was hidden, probably intentional)
  • visible → hidden (visible never had a scrollbar)
  • Overflow changed but content fits (scrollHeight ≤ height)

Detects elements that:

  • Disappeared entirely — present in baseline, absent in current
  • Became hiddenvisibility: visible → hidden
  • Became transparentopacity: 1 → 0

New elements appearing in the current model are not flagged (new features aren't regressions).

Flags when the gap between sibling elements changed beyond spacingThreshold (default: 4px). Also detects inconsistent spacing within a group of siblings (one gap is an outlier from the group median).

Flags when an element moved beyond positionThreshold (default: 20px) on either axis. Reports the delta in both x and y directions.

Flags when width or height changed by more than sizeChangePercent (default: 30%). Only checks elements that had non-zero dimensions in the baseline.

Intent Suppression

Boxy skips checks on elements that are intentionally invisible to all users:

PatternDetectionResult
visibility: hiddenVisually invisibleSuppressed
opacity: 0Fully transparentSuppressed
hasVisibleContent: falseNo rendered contentSuppressed
sr-only patternabsolute, 1×1px, off-screenSuppressed
aria-hidden (alone)Only hides from AT, not visuallyNOT suppressed
aria-hidden="true" does NOT suppress checks. This attribute only removes an element from the accessibility tree — it doesn't mean the element is visually hidden. Decorative Font Awesome icons, SVG icons, and duplicate text commonly use aria-hidden while remaining fully visible and renderable. Clipping a visible aria-hidden element is still a visual bug.

Suppression only activates when the element is invisible to both sighted users and assistive technology.

Spatial Model

Each visible element is captured as an ElementModel with these properties:

selector string
tag string
box {x, y, width, height}
zIndex number
depth number
position string
overflow string
scroll {scrollWidth, scrollHeight, overflowX, overflowY}
clip {isClipped, clippedBy, clippedEdges}
siblingSpacing {previousGap, nextGap, direction}
parentSelector string | null
childCount number
hasVisibleContent boolean
visibility string
opacity string
ariaHidden boolean
role string | null
styles Record<string, string>

The styles object captures computed values for layout-relevant CSS properties: display, position, overflow, width, height, margin, padding, flex properties, grid properties, transform, and clip-path.

Scope captures portaled elements. When using scope, Boxy captures both DOM descendants and any element whose bounding box overlaps the scope element's bounds. This catches React portals, Radix popovers, and other elements appended to <body> that visually appear within the scoped area.
Configuration
OptionDefaultDescription
spacingThreshold4pxSibling spacing changes below this are ignored
positionThreshold20pxPosition shifts below this are ignored
sizeChangePercent30%Size changes below this percentage are ignored
collapsedMinSize5pxElements smaller than this (with content) are flagged as collapsed
ignore[]Array of selector patterns to skip (uses string.includes() matching)
const boxy = createBoxy({
  config: {
    spacingThreshold: 4,
    positionThreshold: 20,
    sizeChangePercent: 30,
    collapsedMinSize: 5,
    ignore: [
      '.scrollable-list',
      '[data-testid="carousel"]',
    ],
  },
});
Known Limitations
  • Closed menus/dropdowns — An absolute-positioned dropdown in its closed state (clipped by parent) looks identical to a broken one. Fix: capture after opening, or add to config.ignore.
  • Animation mid-states — Elements captured during CSS transitions may appear clipped or off-screen temporarily. Fix: wait for animations to settle before capturing.
  • Intentional decorative overflow — Some designs let absolute elements overflow and get clipped (glow effects, rotated badges). Fix: add to config.ignore.
  • Partially off-screen elements — Only fully off-screen triggers. A dropdown 80% off the viewport edge is missed.
  • position: relative clipping — Only absolute/fixed elements trigger CLIPPING.
  • contain: paint — Acts like overflow: hidden but not detected by the capture layer.
  • z-index: 0 overlap — Only elements with z-index > 0 are checked for overlap.
  • JS-driven scroll — Can't detect if overflow: hidden + JavaScript provides scrollability (carousels, virtual scroll). Regression mode catches this if overflow changed.
  • Non-spatial visual changes — Color, font, shadow, border-radius changes are not detected unless they cause spatial side-effects.
API Reference
import { createBoxy } from 'boxy-layout';

const boxy = createBoxy({
  snapshotDir: '.boxy',           // where to store baselines
  update: false,                  // overwrite baselines (or LAYOUT_UPDATE=true)
  allowMissingBaseline: true,     // auto-save on first run
  config: { /* LinterConfig */ },
});

On first run (no baseline), the current layout is auto-saved as the baseline. Set allowMissingBaseline: false to require an existing baseline instead.

Use LAYOUT_UPDATE=true or update: true to overwrite baselines after intentional changes.

// Capture entire page
await boxy.capture(page, { name: 'dashboard' });

// Scope to a specific component
await boxy.capture(page, {
  name: 'action-menu',
  scope: '[data-testid="users-table"]',
});

// Per-capture baseline update
await boxy.capture(page, {
  name: 'redesigned-header',
  update: true,
});

Scope captures DOM descendants plus any element whose bounds overlap the scope area (catches portaled elements).

Pass update: true to overwrite this specific baseline after comparing.

// Print to terminal, returns exit code (1 if errors)
const exitCode = boxy.report();

// Generate HTML report at .boxy/report.html
boxy.writeHTMLReport();

// Check programmatically
if (boxy.hasErrors()) process.exit(1);
// Delete a specific baseline (.json + .png)
boxy.resetBaseline('dashboard');

// Delete all baselines and current snapshots
boxy.resetAllBaselines();

After resetting, the next capture() call will auto-save a fresh baseline.

import { lint, compare } from 'boxy-layout';

// Static lint — single snapshot
const issues = lint(model);
const issues = lint(model, { collapsedMinSize: 10 });

// Regression compare — baseline vs current
const issues = compare(baseline, current);
const issues = compare(baseline, current, {
  positionThreshold: 10,
});

Both return Issue[] with category, severity, selector, title, detail, and optional styleChanges/affectedChildren.