Documentation
How Boxy detects layout bugs, what it catches, what it ignores, and how to configure it.
No idea what or why
overflow: hidden — bottom: 30px"overflow: hidden. That's a math problem, not a vision problem.
For every visible element, Boxy records a JSON model with these fields:
This is pure data — no screenshots needed. The linter and regression engine work entirely on this JSON model, making analysis deterministic and environment-independent.
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.
LAYOUT_UPDATE=true to overwrite baselines. For initial setup, use LAYOUT_INIT=true to accept new baselines without failure.
No baseline needed. Analyses the current page and detects broken patterns from geometry alone:
- Clipping — dropdown extends beyond
overflow:hiddenparent - Overlap — high z-index element clipped while overlapping lower
- Collapsed — near-zero dimensions with visible children
- Off-screen — positioned element fully outside viewport
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.
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: absoluteorposition: 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: staticorrelativeelements — 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)
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: truechildCount > 0
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)
positionis notstatichasVisibleContent: true- Not intentionally hidden (sr-only pattern, visibility:hidden, etc.)
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
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/scrolltohidden/clip scrollHeight > height(orscrollWidth > width)- Checks per-axis:
overflowXandoverflowYindependently
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 hidden —
visibility: visible → hidden - Became transparent —
opacity: 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.
Boxy skips checks on elements that are intentionally invisible to all users:
| Pattern | Detection | Result |
|---|---|---|
| visibility: hidden | Visually invisible | Suppressed |
| opacity: 0 | Fully transparent | Suppressed |
| hasVisibleContent: false | No rendered content | Suppressed |
| sr-only pattern | absolute, 1×1px, off-screen | Suppressed |
| aria-hidden (alone) | Only hides from AT, not visually | NOT 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.
Each visible element is captured as an ElementModel with these properties:
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, 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.
| Option | Default | Description |
|---|---|---|
| spacingThreshold | 4px | Sibling spacing changes below this are ignored |
| positionThreshold | 20px | Position shifts below this are ignored |
| sizeChangePercent | 30% | Size changes below this percentage are ignored |
| collapsedMinSize | 5px | Elements 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"]',
],
},
});
- 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: relativeclipping — Onlyabsolute/fixedelements trigger CLIPPING.contain: paint— Acts likeoverflow: hiddenbut not detected by the capture layer.z-index: 0overlap — Only elements withz-index > 0are 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.
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.