plumbkit
plumbkit
A dev-tool overlay that draws alignment guides over any running web app. Click two elements and plumbkit projects their edges across the viewport so you can see which lines up, which is off by a few pixels, and exactly how much — then hands the fix straight to your coding agent.
It's dev-only by design: everything is gated behind process.env.NODE_ENV
(replaced statically by Vite, webpack, Turbopack, and esbuild), so a production
build tree-shakes the entire tool away to nothing.
Install
npm i -D plumbkit # or: pnpm add -D plumbkit / yarn add -D plumbkit
Zero runtime dependencies in the core. The React adapter has a single optional
peer dependency on react (>= 16.8).
Quick start
Pick whichever fits your app — all three mount the same overlay.
React
import { Plumb } from 'plumbkit/react'
export function App() {
return (
<>
<YourApp />
<Plumb />
</>
)
}
<Plumb> mounts an instance on mount and tears it down on unmount; it renders
nothing. It's enabled in dev by default (process.env.NODE_ENV !== 'production')
and accepts every createPlumb option as a prop.
Next.js (App Router): wrap it in a client component and render it in the root layout, gated on dev:
// components/plumb-devtools.tsx
'use client'
import { Plumb } from 'plumbkit/react'
export function PlumbDevtools() {
return <Plumb />
}
// app/layout.tsx
{process.env.NODE_ENV === 'development' && <PlumbDevtools />}
Auto (zero config, any bundler)
import 'plumbkit/auto'
One import at your entry point and the overlay mounts itself in dev. The instance
is exposed as window.__PLUMB__ for console poking.
Core (imperative)
import { createPlumb } from 'plumbkit'
const plumb = createPlumb()
plumb.activate() // arm the tool (or click the on-screen button)
plumb.select('#card') // add an element to the selection
plumb.on('change', ({ selection }) => console.log(selection))
plumb.destroy() // remove everything
// In production createPlumb returns an inert handle before touching the DOM,
// and the whole implementation is dropped from the bundle.
Using it
The bar spawns in the bottom-left (lifting itself above the Next.js dev
indicator or any other widget already in that corner) with three always-visible
toggles: the
crosshair (alignment guides — A or Alt+G), padding (box-model
overlay — P), and measure (distances between selections — M). Each
toggles independently and they layer: any tool being on enables hover +
click-to-select, so you can inspect spacing without guides, then add guides on
top. Hovering a button shows a tooltip, and each active tool stacks a short
description card top-right. The first few times you turn a tool on, three
small key hints appear top-left (↑↓ walk the tree, Enter toggle,
Esc clear) — each retires for good once you've actually used its key, so
they teach the controls and then get out of the way. Drag the bar to park
the dock anywhere — the spot persists across reloads.
Once armed:
- Click any element to select it. Its edges —
left · center · rightandtop · middle · bottom— draw as hairlines across the whole viewport. - Click more elements to compare. Click a selected element again to drop it.
- Shift-click to click through the tool: the click reaches your app as a normal click, so you can open a dropdown or press a button without disarming, then release Shift and inspect it. While Shift is held the hover highlight hides to show you're in pass-through mode.
- Keyboard:
A/P/Mtoggle the guides / padding / measure tools; arrow keys walk the DOM tree (↑parent,↓child,←ↅsiblings),Enter/Spacetoggles the highlighted element,Escclears.
Padding inspection
Flip the padding sub-toggle to overlay the box model of whatever you hover or select — the way browser devtools shade padding, but live over your app:
- Padding fills green, with a per-side pixel label on each non-zero side.
- Flex / grid gaps fill in a second colour with a single gap label.
- Margins fill orange (no UI toggle — opt in with the
showMarginsoption).
It's border-aware (bands sit inside the border), ignores drop shadows, and runs alongside the alignment guides — leave both on at once.
Measuring between elements
Flip the measure toggle (two-boxes icon), then click two elements: the pixel distance between their nearest edges draws as a magenta ticked line with a label — horizontally, vertically, or both when they sit diagonally apart. Selecting more elements chains the measurements in click order (AB, BC, …); overlapping elements have no gap, so nothing draws for that pair.
Spacing consistency
Select a container and plumbkit also checks whether its direct children are spaced consistently, adding up to two rows to the legend:
- Spacing — whether the children share one padding pattern: a green swatch
means consistent, an amber dashed one means drift. Any child that misses the
dominant padding by a few px gets an amber
pt +2/pl −3label on the offending side. - Rhythm — for children spaced with margins (not a flex/grid gap), the
number of distinct inter-child gaps.
1is an even rhythm; more is uneven.
Both reuse the same tolerance clustering as the alignment engine, and absolutely-positioned children are excluded.
When two or more elements are selected, the guide lines are colour- and pattern-coded:
| Line | Meaning |
|---|---|
| Green, solid | edges that align exactly |
| Amber, dashed | edges that nearly align, with a signed ±px delta label |
| Cobalt, faint | lone edges with no relationship (dimmed so the signal stands out) |
Two panels appear top-right: a legend + tally (how many aligned / near / lone), and "Slightly off", which lists every near-miss with a Copy fix for agent button — it puts a ready-to-paste prompt on your clipboard describing exactly which edges are off and by how much. A separate Copy names button copies the selected components' names and location (React component + DOM path + nearby text) so you can drop them into an agent and say "align these."
API
createPlumb(options?) returns a PlumbHandle:
| Method | Description |
|---|---|
toggle() |
Arm or disarm the tool. |
activate() / deactivate() |
Arm / disarm explicitly. |
select(target) |
Add an Element (or CSS selector) to the selection; arms if needed. |
clear() |
Clear the current selection. |
on(event, fn) |
Subscribe; returns an unsubscribe function. |
destroy() |
Tear down the overlay and all listeners. |
Events: toggle → { active }, select → { target }, change →
{ selection }.
Options
createPlumb(options) / <Plumb {...options} />:
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
dev only | Master switch; false → inert, no DOM work. |
axes |
AxisKey[] |
all six | Which edges/centers to project: left,centerX,right,top,centerY,bottom. |
threshold |
number |
2 |
Gap (px) under which two same-kind edges count as aligned. |
nearTolerance |
number |
16 |
Max gap (px) between same-kind edges of elements that share a row/column to flag a near-miss. |
spacing |
boolean |
false |
Start with the spacing box-model overlay on (toggle it live from the sub-button). |
measure |
boolean |
false |
Start with the measure overlay on — distances between selected elements (toggle it live from the sub-button). |
showMargins |
boolean |
false |
Show margin bands in the spacing overlay (only relevant with spacing; API-only, no UI toggle). |
theme |
PlumbTheme |
— | Overrides for the --plumb-* colour/size tokens. |
color |
string |
cobalt | Shorthand for theme.accent. |
hotkey |
string |
'alt+g' |
Shortcut that arms/disarms the tool. |
root |
HTMLElement |
document.body |
Where the overlay host mounts. |
Theming
Every colour and size is a --plumb-* custom property scoped to the overlay's
shadow host (defined in OKLCH). Override any subset via theme:
createPlumb({
theme: {
accent: 'oklch(0.7 0.16 320)', // selected / lone edges, armed button
match: 'oklch(0.8 0.16 145)', // aligned edges
near: 'oklch(0.8 0.15 70)', // near-miss edges
bg: 'oklch(0.2 0.02 300)',
radius: '6px',
},
})
Keys include accent, match, near, lone, warn, bg, bgAlt, fg,
muted, border, ink, shadow, fab, gap, radius, lineWidth, the
spacing fills paddingFill / gapFill / marginFill, the measure line
colour, and the *Opacity / labelSize / fontSans guide tokens.
How alignment detection works
For the current selection, plumbkit:
- Measures each selected element's rect (skipping its own UI and anything
display:noneor zero-size). - Derives up to six axis coordinates per rect:
left / centerX / right(→ vertical guides) andtop / centerY / bottom(→ horizontal guides). - Clusters per axis in 1-D — sorted, single pass, starting a new cluster
when the gap to the previous value exceeds
threshold; a cluster's coordinate is the median of its members. Clustering within each axis means a right edge sitting next to a left edge is read as spacing, not alignment — it's never mis-flagged. - Classifies each cluster:
- aligned — two or more same-kind edges share the line (green).
- near — a lone edge that misses another same-axis edge whose element
shares its row/column (amber, dashed, with a signed
±pxdelta). Two side-by-side panels compare their tops/bottoms (same row) but not their lefts (different columns), so intentional layout gaps aren't flagged. - lone — nothing nearby (cobalt, faint).
Guides span the full viewport, so elements far apart on the page can still be
compared. Everything is drawn in raw getBoundingClientRect viewport
coordinates (the host is position:fixed) and re-tracked on scroll, resize, and
a throttled rAF heartbeat — no scroll math.
Accessibility & fit
- Keyboard-operable end to end (DOM-tree element picker, focusable controls with visible focus rings).
- Not colour-only — near-miss lines are dashed as well as amber, and status
changes are announced via an
aria-liveregion. - Respects
prefers-reduced-motion. - Stays out of the way — the chrome is corner-docked and flips sides to avoid fixed app widgets; it never dismisses your app's own modals/popovers.
- Isolated in a shadow DOM at max z-index, with its font bundled as a
data:URI so it renders even under a strict Content-Security-Policy.
License
MIT