Developer
Inspect
A development aid that outlines the element you point at and shows its size, padding and gap.
- Built on
getComputedStylegetBoundingClientRectPointer events- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.8 kB
- Dependencies
- React
Code
import { Button } from "@/components/equip/button";
import { Inspect } from "@/components/equip/inspect";
export default function Demo() {
return (
<Inspect active style={{ padding: "16px 0 0 16px" }}>
<div
style={{
display: "grid",
gap: 8,
width: 184,
padding: 12,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
}}
>
<div style={{ display: "grid", gap: 2 }}>
<strong style={{ fontSize: 13 }}>Rotate API key</strong>
<span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Last rotated 82 days ago</span>
</div>
<Button size="s" variant="outline">
Rotate key
</Button>
</div>
</Inspect>
);
}Examples
Hold to measure
Hold Alt and Shift (Option and Shift on Mac) and point, or focus a control first and then hold the keys.
Code
import { Button } from "@/components/equip/button";
import { Inspect } from "@/components/equip/inspect";
export default function Example() {
return (
<Inspect style={{ padding: "16px 0 0 16px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
width: "100%", maxWidth: 320,
padding: "12px 16px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
}}
>
<div style={{ display: "grid", gap: 2 }}>
<strong style={{ fontSize: 13 }}>Weekly digest</strong>
<span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Mondays at 08:00, 14 recipients</span>
</div>
<Button size="s">Edit</Button>
</div>
</Inspect>
);
}Only buttons
With match set to .eq-button, pointing at a label or icon inside a button measures the button, and the text around it is skipped.
Delete the staging database? Snapshots older than 7 days go with it.
Code
import { Button } from "@/components/equip/button";
import { Inspect } from "@/components/equip/inspect";
export default function Example() {
return (
<Inspect active match=".eq-button" style={{ padding: "16px 0 0 16px" }}>
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 320 }}>
<p style={{ margin: 0, fontSize: 13, color: "var(--eq-ink-2)" }}>
Delete the staging database? Snapshots older than 7 days go with it.
</p>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button variant="outline">Cancel</Button>
<Button variant="danger">Delete database</Button>
</div>
</div>
</Inspect>
);
}Gap in a flex row
Point at the row rather than a tag: Gap only appears for flex and grid containers, and padding shows as hatching.
Code
import { Inspect } from "@/components/equip/inspect";
const tags = ["eu-west-1", "postgres-16", "read-replica"];
export default function Example() {
return (
<Inspect active style={{ padding: "16px 0 0 16px" }}>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
padding: 12,
border: "1px dashed var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
}}
>
{tags.map((tag) => (
<span
key={tag}
style={{
display: "inline-flex",
alignItems: "center",
height: 20,
paddingInline: 6,
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint-strong)",
fontSize: 12,
fontWeight: 650,
}}
>
{tag}
</span>
))}
</div>
</Inspect>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
active | boolean | false | Always measuring, without holding a key. |
modifier | "Alt+Shift" | "Alt" | "Shift" | "Alt+Shift" | Keys held to measure. Ignored when active is set. |
match | string | "*" | CSS selector for what can be picked. The closest match to the pointer or focus wins. |
children | ReactNode | none | The region to measure. |
...rest | ComponentProps<"div"> | none | Every native div attribute, on the root. |
Anatomy
| root | .eq-inspect | A positioned div. Gains data-active while measuring. |
|---|---|---|
| overlay | .eq-inspect-overlay | Everything drawn, inside the root, hidden from assistive tech. |
| box | .eq-inspect-box | The outline around the picked element. |
| padding | .eq-inspect-pad | A hatched band on each padded side. |
| dimension | .eq-inspect-dim | Width above and height to the left, set by data-axis. |
| label | .eq-inspect-label | The number on a dimension line. |
| card | .eq-inspect-card | The readout. |
| name | .eq-inspect-name | The element's eq- class, or its tag and first class. |
| list | .eq-inspect-list | |
| row | .eq-inspect-row |
Keyboard
| AltShift | Hold to measure; release to clear. Not needed when active is set. |
|---|---|
| Tab | While measuring, moving focus inside the region measures the focused element. |
Notes
- A development aid for design review builds, not for production screens.
- Values are computed styles in CSS px, so they show what the browser resolved, not which token set them.
- The overlay is drawn inside the root and takes no pointer events, so the region stays usable while you measure.
- Gap is listed only for flex and grid containers; padding shows as hatching on each padded side.
- Nothing is drawn until the pointer or focus is inside the region, so it looks unchanged at rest.
- The readout can spill past the root; an ancestor with overflow hidden will clip it.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/inspect.tsx256 lines
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ComponentProps,
type FocusEvent,
type PointerEvent,
type ReactNode,
type Ref,
} from "react";
import "./inspect.css";
type Modifier = "Alt+Shift" | "Alt" | "Shift";
export type InspectProps = ComponentProps<"div"> & {
/** Always measuring, without holding a key. */
active?: boolean;
/** Keys to hold to measure. Ignored when active is set. */
modifier?: Modifier;
/** CSS selector for which descendants can be picked. The closest match to the pointer wins. */
match?: string;
children: ReactNode;
};
type Box = { x: number; y: number; w: number; h: number };
type Reading = {
box: Box;
pad: [number, number, number, number];
name: string;
rows: [string, string][];
card: { left: number; top: number };
};
const px = (value: string) => parseFloat(value) || 0;
const fmt = (n: number) => String(Math.round(n * 10) / 10);
function held(event: KeyboardEvent, modifier: Modifier) {
if (event.ctrlKey || event.metaKey) return false;
if (modifier === "Alt+Shift") return event.altKey && event.shiftKey;
if (modifier === "Alt") return event.altKey && !event.shiftKey;
return event.shiftKey && !event.altKey;
}
function assignRef<T>(ref: Ref<T> | undefined, node: T | null) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
function sides([t, r, b, l]: number[]) {
if (t === b && r === l) return t === r ? fmt(t) : `${fmt(t)} ${fmt(r)}`;
return [t, r, b, l].map(fmt).join(" ");
}
/** Computed values in CSS px, with the box placed relative to the root's padding box. */
function read(el: Element, root: HTMLElement): Reading {
const cs = getComputedStyle(el);
const rect = el.getBoundingClientRect();
const base = root.getBoundingClientRect();
const box = {
x: rect.left - base.left - root.clientLeft + root.scrollLeft,
y: rect.top - base.top - root.clientTop + root.scrollTop,
w: rect.width,
h: rect.height,
};
const pad = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(px) as Reading["pad"];
const rows: [string, string][] = [["Size", `${fmt(rect.width)} × ${fmt(rect.height)}`]];
rows.push(["Padding", pad.some(Boolean) ? sides(pad) : "0"]);
if (/flex|grid/.test(cs.display)) {
const row = px(cs.rowGap);
const column = px(cs.columnGap);
rows.push(["Gap", row === column ? fmt(row) : `${fmt(row)} ${fmt(column)}`]);
}
const radii = [cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius].map(px);
if (radii.some(Boolean)) {
const pill = Math.min(...radii) >= Math.min(rect.width, rect.height) / 2;
rows.push(["Radius", pill ? "full" : sides(radii)]);
}
rows.push(["Font size", fmt(px(cs.fontSize))]);
const eq = [...el.classList].find((c) => c.startsWith("eq-"));
const first = el.classList[0];
const name = eq ? `.${eq}` : `${el.tagName.toLowerCase()}${first ? `.${first}` : ""}`;
// The card goes below the element when the viewport has room there, otherwise above it.
const height = 34 + rows.length * 18;
const roomBelow = window.innerHeight - rect.bottom;
const below = roomBelow >= height + 12 || roomBelow >= rect.top;
const card = { left: Math.max(0, box.x), top: below ? box.y + box.h + 10 : box.y - height - 22 };
return { box, pad, name, rows, card };
}
export function Inspect({
active = false,
modifier = "Alt+Shift",
match = "*",
className,
children,
ref,
onPointerOver,
onPointerLeave,
onFocus,
onBlur,
...rest
}: InspectProps) {
const root = useRef<HTMLDivElement | null>(null);
const overlay = useRef<HTMLDivElement | null>(null);
// The raw element last pointed at and last focused, kept so that pressing the
// modifier with the pointer already resting draws at once.
const pointed = useRef<Element | null>(null);
const focused = useRef<Element | null>(null);
const lastBy = useRef<"pointer" | "focus">("pointer");
const [holding, setHolding] = useState(false);
const [reading, setReading] = useState<Reading | null>(null);
const on = active || holding;
const pick = useCallback(
(raw: Element | null) => {
const own = root.current;
if (!own || !raw || overlay.current?.contains(raw)) return null;
let el: Element | null = null;
try {
el = raw.closest(match);
} catch {
return null;
}
return el && el !== own && own.contains(el) ? el : null;
},
[match],
);
const measure = useCallback(() => {
const own = root.current;
const source = lastBy.current === "pointer" ? (pointed.current ?? focused.current) : (focused.current ?? pointed.current);
const el = own && on ? pick(source) : null;
setReading(el && own && el.isConnected ? read(el, own) : null);
}, [on, pick]);
// Track the modifier. Not needed, and not added, when always active.
useEffect(() => {
if (active) return;
const update = (event: KeyboardEvent) => setHolding(held(event, modifier));
const release = () => setHolding(false);
window.addEventListener("keydown", update);
window.addEventListener("keyup", update);
window.addEventListener("blur", release);
return () => {
window.removeEventListener("keydown", update);
window.removeEventListener("keyup", update);
window.removeEventListener("blur", release);
};
}, [active, modifier]);
useEffect(() => {
measure();
}, [measure]);
// Follow the target through scrolling and resizing while something is drawn.
const drawn = reading !== null;
useEffect(() => {
if (!drawn) return;
let frame = 0;
const later = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
window.addEventListener("scroll", later, { capture: true, passive: true });
window.addEventListener("resize", later);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", later, { capture: true });
window.removeEventListener("resize", later);
};
}, [drawn, measure]);
const [pt, pr, pb, pl] = reading?.pad ?? [0, 0, 0, 0];
return (
<div
{...rest}
ref={(node) => {
root.current = node;
assignRef(ref, node);
}}
className={["eq-inspect", className].filter(Boolean).join(" ")}
data-active={on || undefined}
onPointerOver={(event: PointerEvent<HTMLDivElement>) => {
onPointerOver?.(event);
pointed.current = event.target instanceof Element ? event.target : null;
lastBy.current = "pointer";
if (on) measure();
}}
onPointerLeave={(event: PointerEvent<HTMLDivElement>) => {
onPointerLeave?.(event);
pointed.current = null;
if (on) measure();
}}
onFocus={(event: FocusEvent<HTMLDivElement>) => {
onFocus?.(event);
// Keyboard focus only: a mouse click would otherwise leave the button measured.
if (!event.target.matches(":focus-visible")) return;
focused.current = event.target;
lastBy.current = "focus";
if (on) measure();
}}
onBlur={(event: FocusEvent<HTMLDivElement>) => {
onBlur?.(event);
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
focused.current = null;
if (on) measure();
}}
>
{children}
{reading && (
<div ref={overlay} className="eq-inspect-overlay" aria-hidden="true">
<div
className="eq-inspect-box"
style={{ left: reading.box.x, top: reading.box.y, width: reading.box.w, height: reading.box.h }}
>
{pt > 0 && <i className="eq-inspect-pad" style={{ inset: "0 0 auto 0", height: pt }} />}
{pb > 0 && <i className="eq-inspect-pad" style={{ inset: "auto 0 0 0", height: pb }} />}
{pl > 0 && <i className="eq-inspect-pad" style={{ inset: `${pt}px auto ${pb}px 0`, width: pl }} />}
{pr > 0 && <i className="eq-inspect-pad" style={{ inset: `${pt}px 0 ${pb}px auto`, width: pr }} />}
</div>
<div
className="eq-inspect-dim"
data-axis="width"
style={{ left: reading.box.x, top: reading.box.y - 11, width: reading.box.w }}
>
<b className="eq-inspect-label">{fmt(reading.box.w)}</b>
</div>
<div
className="eq-inspect-dim"
data-axis="height"
style={{ left: reading.box.x - 11, top: reading.box.y, height: reading.box.h }}
>
<b className="eq-inspect-label">{fmt(reading.box.h)}</b>
</div>
<div className="eq-inspect-card" style={{ left: reading.card.left, top: reading.card.top }}>
<div className="eq-inspect-name">{reading.name}</div>
<dl className="eq-inspect-list">
{reading.rows.map(([label, value]) => (
<div key={label} className="eq-inspect-row">
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</div>
</div>
)}
</div>
);
}components/equip/inspect.css112 lines
@layer equip {
.eq-inspect {
position: relative;
}
.eq-inspect[data-active] {
cursor: crosshair;
}
/* Everything drawn sits in the root's own box and never takes the pointer. */
.eq-inspect-overlay {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
}
.eq-inspect-overlay > * {
position: absolute;
}
.eq-inspect-box {
box-sizing: border-box;
outline: 1px solid var(--eq-focus);
}
.eq-inspect-pad {
position: absolute;
background: repeating-linear-gradient(
135deg,
color-mix(in srgb, var(--eq-focus) 60%, transparent) 0 1px,
transparent 1px 4px
);
}
.eq-inspect-dim {
box-sizing: border-box;
border: 0 solid var(--eq-focus);
}
/* A dimension line with end ticks, its label floating off the outer side. */
.eq-inspect-dim[data-axis="width"] {
height: 7px;
border-inline-width: 1px;
background: linear-gradient(var(--eq-focus), var(--eq-focus)) center / 100% 1px no-repeat;
}
.eq-inspect-dim[data-axis="height"] {
width: 7px;
border-block-width: 1px;
background: linear-gradient(var(--eq-focus), var(--eq-focus)) center / 1px 100% no-repeat;
}
.eq-inspect-label {
position: absolute;
padding: 1px var(--eq-space-1);
border-radius: var(--eq-radius-s);
background: var(--eq-focus);
color: var(--eq-bg);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1.2 var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
white-space: nowrap;
}
.eq-inspect-dim[data-axis="width"] .eq-inspect-label {
bottom: 9px;
left: 50%;
translate: -50%;
}
.eq-inspect-dim[data-axis="height"] .eq-inspect-label {
top: 50%;
right: 9px;
translate: 0 -50%;
}
.eq-inspect-card {
box-sizing: border-box;
min-width: 152px;
padding: var(--eq-space-2) var(--eq-space-3);
border-radius: var(--eq-radius-m);
background: var(--eq-solid);
color: var(--eq-solid-ink);
box-shadow: var(--eq-shadow);
font: var(--eq-weight) var(--eq-text-xs) / 18px var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
white-space: nowrap;
}
.eq-inspect-name {
margin-bottom: 2px;
font-weight: var(--eq-weight-strong);
}
.eq-inspect-list {
margin: 0;
}
.eq-inspect-row {
display: grid;
grid-template-columns: 9ch auto;
gap: var(--eq-space-2);
}
.eq-inspect-row dt {
color: color-mix(in srgb, var(--eq-solid-ink) 62%, transparent);
}
.eq-inspect-row dd {
margin: 0;
}
}