Drop-in layout linter for Playwright. Add a few lines to your existing tests to catch clipping, overflow, and CSS regressions automatically.
Not pixel diffing. Boxy builds a spatial model of your rendered page and detects broken layout patterns. When something breaks, it tells you which CSS property changed on which element caused it — verified by re-rendering with each change in isolation.
The worst UI bugs are the ones where:
- Someone adds
overflow: hiddento a container and now dropdowns are clipped - A modal gets
overflow: hiddenand select menus inside it are cut off - A sidebar collapses to 0px and all nav items disappear
- An element gets
visibility: hiddenoropacity: 0and nobody notices - A z-index change buries a popover behind another element
These bugs are invisible to pixel diffing because they only appear after specific interactions (open menu, scroll, open another menu), depend on content length or viewport size, and the baseline screenshot was taken in a different state.
npm install -D boxy-layoutAdd capture() calls to your existing Playwright tests after interactions:
import { createBoxy } from 'boxy-layout';
const boxy = createBoxy();
test('users page', async ({ page }) => {
await page.goto('/users');
await boxy.capture(page, { name: 'users-loaded' });
// Open a dropdown — does it get clipped?
await page.click('[data-testid="filter-status"]');
await boxy.capture(page, { name: 'filter-open' });
// Open action menu on last row — does it overflow the table?
await page.click('[data-testid="action-btn"]');
await boxy.capture(page, {
name: 'action-menu',
scope: '[data-testid="users-table"]',
});
});
afterAll(() => {
const exitCode = boxy.report();
boxy.writeHTMLReport();
if (exitCode) process.exit(exitCode);
});Baselines are auto-saved on first run, but missing baselines fail by default so CI catches an absent trust anchor. For an intentional setup run:
LAYOUT_INIT=true npx playwright testOn the first run, Boxy saves the current layout as the baseline and runs lint checks only (no regression comparison). Without LAYOUT_INIT=true or acceptNewBaselines: true, that baseline-created notice is reported as a failure.
On subsequent runs, Boxy compares against the saved baseline and reports both lint issues and regressions.
When you intentionally change the layout, update baselines:
LAYOUT_UPDATE=true npx playwright testOr per-capture:
await boxy.capture(page, { name: 'redesigned-header', update: true });boxy.resetBaseline('dashboard'); // delete one baseline
boxy.resetAllBaselines(); // clear all baselines and current snapshots| Check | What it catches |
|---|---|
| Clipping | Element extends beyond parent with overflow: hidden/auto/scroll |
| Overlap | Positioned elements overlapping where high z-index is clipped |
| Collapsed | Element has near-zero width/height but contains content |
| Off-screen | Positioned element outside viewport bounds |
| Check | What it catches |
|---|---|
| Spacing | Gap between siblings changed beyond threshold |
| Position | Element shifted significantly from baseline |
| Size | Element width/height changed >30% |
| Visibility | Element disappeared, became visibility:hidden, or opacity:0 |
| CSS diff | Shows which computed styles changed on which elements |
When a regression is detected, Boxy can prove exactly which CSS property caused it by re-rendering with each change applied in isolation:
// After capturing a regression
const step = await boxy.capture(page, { name: 'detail', scope: '[data-testid="panel"]' });
// Revert the page to baseline state, then run causation analysis
const result = await boxy.diagnoseCauses(page, 'detail');┌─────────────────────────────────────────────────────────┐
│ Causation Analysis │
└─────────────────────────────────────────────────────────┘
8 re-renders performed
┌ ROOT CAUSES (1 verified)
│ [data-testid="detail-body"]
│ maxHeight: none → 150px
│ → shifted 39 elements, resized 2 elements
└
┌ NO IMPACT (6 verified)
│ [data-testid="detail-header"]
│ zIndex: auto → 99
│ [data-testid="detail-close"]
│ maxWidth: none → 9999px
│ [data-testid="detail-body"]
│ overflow: auto → hidden
│ [data-testid="detail-body"]
│ overflowX: auto → hidden
│ [data-testid="detail-body"]
│ overflowY: auto → hidden
└
7 CSS properties changed. Only 1 actually broke the layout. Boxy proves it.
const boxy = createBoxy({
snapshotDir: '.boxy', // where to store baselines
update: false, // set true to overwrite baselines (or use LAYOUT_UPDATE=true)
allowMissingBaseline: true, // auto-save baseline on first run (set false to require existing baseline)
acceptNewBaselines: false, // set true for intentional setup runs (or use LAYOUT_INIT=true)
config: {
spacingThreshold: 4, // px — spacing changes below this are ignored
positionThreshold: 20, // px — position shifts below this are ignored
sizeChangePercent: 30, // % — size changes below this are ignored
collapsedMinSize: 5, // px — elements smaller than this are flagged
ignore: [ // selectors to skip
'.scrollable-list',
'[data-testid="carousel"]',
],
},
});┌─────────────────────────────────────────────────────────┐
│ Layout Lint Results │
└─────────────────────────────────────────────────────────┘
✓ users-loaded
✓ filter-open
✗ action-menu (4 errors, 0 warnings)
┌ CLIPPING
│ ✗ Element clipped by parent overflow
│ [data-testid="action-menu"]
│ clipped by: [data-testid="users-table"]
│ hidden: bottom: 94px
└
┌ CSS CHANGES (caused layout impact)
│ [data-testid="users-table"]
│ overflow: visible → hidden
└
Generated at .boxy/report.html with screenshots and issue details.
Exit code 1 when errors are found:
- run: npx playwright test# Smoke test (good version should pass clean)
npm test
# Broken version should catch clipping errors
npm run test:broken
# Interaction-driven scenario tests
npm run test:scenarios
npm run test:scenarios:broken
# Mutation suite: inject 9 realistic CSS bugs, verify all are caught
npm run test:mutationsCurrent mutation detection rate: 9/9 (100%)
| Mutation | What it simulates |
|---|---|
table-overflow-hidden |
overflow:hidden added to scrollable table container |
modal-overflow-hidden |
Modal clips select dropdowns inside forms |
sidebar-collapse-to-zero |
Collapsed sidebar width set to 0 instead of icon-width |
detail-panel-overflow-hidden |
Detail panel body loses scroll, clips long content |
filter-popover-under-table |
Filter popover z-index lowered, goes behind sticky header |
notif-dropdown-off-screen |
Notification dropdown positioned with wrong offset |
row-dropdown-opens-down |
Action menu opens downward instead of upward on last rows |
nav-items-visibility-hidden |
Nav items set to visibility:hidden |
pagination-opacity-zero |
Pagination faded to opacity:0 |
- Captures the bounding box, computed styles, z-index, overflow, visibility, opacity, and sibling spacing for every visible element
- Lints the spatial model for broken patterns (clipping, overlap, collapse)
- Compares against baseline (if available) for regressions (spacing, position, size, visibility)
- Diffs computed CSS between baseline and current, classifying changes as effective (caused spatial impact) or inert (no layout difference)
- Verifies causation (optional) to identify which specific CSS property caused the breakage
| Pixel diffing | Boxy | |
|---|---|---|
| False positives | High (anti-aliasing, fonts, rendering) | Low (spatial analysis) |
| Explains the bug | "These pixels changed" | "Dropdown clipped because overflow: auto → hidden" |
| Identifies root cause | No | Yes — pinpoints the CSS property that caused it |
| Needs cloud infra | Yes | No (runs locally) |
| Needs baseline | Always | Only for regression checks |
| Post-interaction testing | Only if you screenshot every state | Captures spatial model after any interaction |
MIT