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
Rotate API keyLast rotated 82 days ago
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.

Weekly digestMondays at 08:00, 14 recipients
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.

eu-west-1postgres-16read-replica
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

PropTypeDefaultWhat it does
activebooleanfalseAlways measuring, without holding a key.
modifier"Alt+Shift" | "Alt" | "Shift""Alt+Shift"Keys held to measure. Ignored when active is set.
matchstring"*"CSS selector for what can be picked. The closest match to the pointer or focus wins.
childrenReactNodenoneThe region to measure.
...restComponentProps<"div">noneEvery native div attribute, on the root.

Anatomy

root.eq-inspectA positioned div. Gains data-active while measuring.
overlay.eq-inspect-overlayEverything drawn, inside the root, hidden from assistive tech.
box.eq-inspect-boxThe outline around the picked element.
padding.eq-inspect-padA hatched band on each padded side.
dimension.eq-inspect-dimWidth above and height to the left, set by data-axis.
label.eq-inspect-labelThe number on a dimension line.
card.eq-inspect-cardThe readout.
name.eq-inspect-nameThe element's eq- class, or its tag and first class.
list.eq-inspect-list
row.eq-inspect-row

Keyboard

AltShiftHold to measure; release to clear. Not needed when active is set.
TabWhile 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index