Developer

Annotate

Numbered pins with notes dropped anywhere on a wrapped design, for design review and feedback builds.

Built on
Popover APIPointer capture@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.8 kB
Built from
ButtonTextarea
Team plan
14 per seat
Upgrade
Arrow keys move it, Enter edits, Delete removes.
Code
import { Annotate } from "@/components/equip/annotate";

const card = {
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  height: 52,
  padding: "0 12px",
  border: "1px solid var(--eq-line)",
  borderRadius: "var(--eq-radius-m)",
  background: "var(--eq-raised)",
  font: "var(--eq-text-s) / 1.2 var(--eq-font)",
} as const;

export default function Demo() {
  return (
    <Annotate
      label="Pricing card"
      style={{ width: 216, font: "var(--eq-text-xs) / 1.35 var(--eq-font)" }}
      defaultNotes={[
        { id: "price", x: 30, y: 72, text: "Raise price contrast" },
        { id: "cta", x: 84, y: 18, text: "Match button radius" },
      ]}
    >
      <div style={card}>
        <div>
          <strong>Team plan</strong>
          <div style={{ color: "var(--eq-ink-3)" }}>14 per seat</div>
        </div>
        <span style={{ padding: "6px 10px", borderRadius: "var(--eq-radius-s)", background: "var(--eq-solid)", color: "var(--eq-solid-ink)" }}>
          Upgrade
        </span>
      </div>
    </Annotate>
  );
}

Examples

Saving a review

Notes are plain data with percentage positions, so they survive a resize and can be stored next to the build they review.

Close the books in a day, not a weekReconcile every account automatically and hand auditors a clean trail.
Arrow keys move it, Enter edits, Delete removes.
1 note ready to send with the review
Code
"use client";

import { useState } from "react";
import { Annotate, type AnnotateNote } from "@/components/equip/annotate";

const hero = {
  display: "grid",
  gap: 8,
  padding: 24,
  border: "1px solid var(--eq-line)",
  borderRadius: "var(--eq-radius-l)",
  background: "var(--eq-surface)",
} as const;

export default function Example() {
  const [notes, setNotes] = useState<AnnotateNote[]>([
    { id: "h1", x: 22, y: 26, text: "Headline wraps at 1280, shorten to one line" },
  ]);
  return (
    <div style={{ display: "grid", gap: 12, maxWidth: 420 }}>
      <Annotate label="Landing page hero, build 214" notes={notes} onNotesChange={setNotes}>
        <div style={hero}>
          <strong style={{ fontSize: "var(--eq-text-l)" }}>Close the books in a day, not a week</strong>
          <span style={{ color: "var(--eq-ink-2)" }}>Reconcile every account automatically and hand auditors a clean trail.</span>
        </div>
      </Annotate>
      <code style={{ fontFamily: "var(--eq-font-mono)", fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>
        {notes.length} {notes.length === 1 ? "note" : "notes"} ready to send with the review
      </code>
    </div>
  );
}

Read-only after review

With active off, the design works normally, pins are markers only and the numbered list carries the feedback.

Code
import { Button } from "@/components/equip/button";
import { Field } from "@/components/equip/field";
import { Annotate } from "@/components/equip/annotate";

export default function Example() {
  return (
    <Annotate
      label="Signup form, round 2"
      active={false}
      style={{ maxWidth: 320 }}
      defaultNotes={[
        { id: "a", x: 92, y: 62, text: "Show the password rules before the first attempt" },
        { id: "b", x: 92, y: 85, text: "Create account should be the only accent button" },
      ]}
    >
      <form style={{ display: "grid", gap: 8, padding: 16, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)" }}>
        <Field id="annotate-ro-email" size="s" type="email" label="Work email" placeholder="name@company.com" />
        <Field id="annotate-ro-password" size="s" type="password" label="Password" placeholder="At least 12 characters" />
        <Button type="button" size="s">
          Create account
        </Button>
      </form>
    </Annotate>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the reviewed region, such as "Checkout page, desktop".
notes{ id: string; x: number; y: number; text: string }[]noneControlled notes. x and y are percentages of the region, so pins survive resizing.
defaultNotesAnnotateNote[]noneStarting notes when uncontrolled.
onNotesChange(notes: AnnotateNote[]) => voidnoneCalled with the full list after every add, move, edit and delete.
activebooleantrueWhen false the region behaves normally, pins are read-only markers and only the list remains.
childrenReactNodenoneThe design under review.
...restComponentProps<"div">noneNative attributes for the root, which is a labelled group.

Anatomy

root.eq-annotate
region.eq-annotate-regionWraps the children and positions the pins.
layer.eq-annotate-layerCovers the design while active and catches the click that adds a pin.
pin.eq-annotate-pinA button while active, a hidden marker when read-only.
footer.eq-annotate-footer
list.eq-annotate-listEvery note in order, readable without finding pins.
item.eq-annotate-item
number.eq-annotate-number
text.eq-annotate-text
empty.eq-annotate-emptyShown while active with no notes.
add.eq-annotate-addPlaces a pin at the centre, the keyboard way to add a note.
hint.eq-annotate-hintVisually hidden key help, the description of every pin.
editor.eq-annotate-editorA popover in the top layer, so an overflow: hidden region never clips it.
actions.eq-annotate-actions

Keyboard

EnterSpaceOn Add note, places a pin at the centre and opens its editor.
EnterSpaceOn a pin, opens its editor.
ArrowLeftArrowRightArrowUpArrowDownOn a pin, moves it by 1% of the region.
ShiftArrow keysOn a pin, moves it by 5%.
DeleteBackspaceOn a pin, removes it and moves focus to the next pin or Add note.
CtrlEnterIn the editor, saves the note. Cmd on macOS.
EscapeCloses the editor without saving and returns focus to the pin.

Notes

  • A new pin closed with no text is removed, so a stray click leaves nothing behind.
  • Clicking outside an open editor closes it; a click on the design then starts the next pin.
  • Pins drag with pointer capture and stay inside the region; a press without movement opens the note instead.
  • While active the layer covers the design, so its own links and buttons do not react to clicks; set active to false to use it.
  • The ordered list repeats every note, so screen reader users never have to hunt for pins.
  • The editor needs the Popover API. Without it pins and the list still work but the editor does not open.

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/annotate.tsx374 lines
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type ReactNode,
  type ToggleEvent,
} from "react";
import { Button } from "./button";
import { Textarea } from "./textarea";
import "./annotate.css";

export type AnnotateNote = { id: string; x: number; y: number; text: string };

export type AnnotateProps = Omit<ComponentProps<"div">, "children" | "defaultValue"> & {
  /** Names the reviewed region, e.g. "Checkout page, desktop". */
  label: string;
  /** Controlled notes. x and y are percentages of the region. */
  notes?: AnnotateNote[];
  /** Starting notes when uncontrolled. */
  defaultNotes?: AnnotateNote[];
  onNotesChange?: (notes: AnnotateNote[]) => void;
  /** When false the region behaves normally and pins are read-only. */
  active?: boolean;
  /** The design under review. */
  children: ReactNode;
};

const GAP = 8;
const DRAG_SLOP = 3;

const clamp = (n: number) => Math.round(Math.min(100, Math.max(0, n)) * 10) / 10;

type Drag = {
  id: string;
  pointerId: number;
  x0: number;
  y0: number;
  x: number;
  y: number;
  moved: boolean;
  rect: DOMRect;
};

export function Annotate({
  label,
  notes: notesProp,
  defaultNotes,
  onNotesChange,
  active = true,
  className,
  children,
  ...rest
}: AnnotateProps) {
  const uid = useId();
  const [internal, setInternal] = useState<AnnotateNote[]>(defaultNotes ?? []);
  const notes = notesProp ?? internal;
  const [editing, setEditing] = useState<string | null>(null);
  const [draft, setDraft] = useState("");
  const [dragPos, setDragPos] = useState<{ id: string; x: number; y: number } | null>(null);
  const layerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<HTMLDivElement>(null);
  const textRef = useRef<HTMLTextAreaElement>(null);
  const addRef = useRef<HTMLButtonElement>(null);
  const pins = useRef(new Map<string, HTMLButtonElement>());
  const drag = useRef<Drag | null>(null);
  const swallowClick = useRef(false);
  const fresh = useRef<string | null>(null);
  const seq = useRef(0);
  /** Where focus goes after the next render: a note id, or null for the Add note button. */
  const focusNext = useRef<string | null | undefined>(undefined);

  function commit(next: AnnotateNote[]) {
    if (notesProp === undefined) setInternal(next);
    onNotesChange?.(next);
  }

  /** Notes without the previous blank pin, which is dropped when the next one starts. */
  function withoutFresh() {
    const id = fresh.current;
    fresh.current = null;
    return id ? notes.filter((n) => n.id !== id) : notes;
  }

  function open(note: AnnotateNote) {
    if (fresh.current && fresh.current !== note.id) commit(withoutFresh());
    setDraft(note.text);
    setEditing(note.id);
  }

  function create(x: number, y: number) {
    seq.current += 1;
    const note = { id: `${uid}${seq.current}`, x: clamp(x), y: clamp(y), text: "" };
    commit([...withoutFresh(), note]);
    fresh.current = note.id;
    setDraft("");
    setEditing(note.id);
  }

  function addAtCentre() {
    let x = 50;
    let y = 50;
    while (notes.some((n) => Math.abs(n.x - x) < 2 && Math.abs(n.y - y) < 2) && x < 90) {
      x += 4;
      y += 4;
    }
    create(x, y);
  }

  function remove(id: string) {
    const i = notes.findIndex((n) => n.id === id);
    focusNext.current = notes[i + 1]?.id ?? notes[i - 1]?.id ?? null;
    if (fresh.current === id) fresh.current = null;
    commit(notes.filter((n) => n.id !== id));
    if (editing === id) editorRef.current?.hidePopover();
  }

  function move(id: string, x: number, y: number) {
    commit(notes.map((n) => (n.id === id ? { ...n, x: clamp(x), y: clamp(y) } : n)));
  }

  function save() {
    const text = draft.trim();
    if (!editing || !text) return;
    fresh.current = null;
    commit(notes.map((n) => (n.id === editing ? { ...n, text } : n)));
    editorRef.current?.hidePopover();
  }

  useEffect(() => {
    const target = focusNext.current;
    if (target === undefined) return;
    focusNext.current = undefined;
    ((target && pins.current.get(target)) || addRef.current)?.focus();
  });

  useEffect(() => {
    const editor = editorRef.current;
    const pin = editing ? pins.current.get(editing) : undefined;
    if (!editor || !pin || typeof editor.showPopover !== "function") return;
    if (!editor.matches(":popover-open")) editor.showPopover();
    // Placed by its pin, and placed again when the window resizes or the page scrolls under it.
    function place() {
      if (!editor || !pin) return;
      const r = pin.getBoundingClientRect();
      const w = editor.offsetWidth;
      const h = editor.offsetHeight;
      const vw = document.documentElement.clientWidth;
      const vh = document.documentElement.clientHeight;
      const below = r.bottom + GAP + h <= vh - GAP || r.top - GAP - h < GAP;
      const x = r.left + r.width / 2 - w / 2;
      const y = below ? r.bottom + GAP : r.top - GAP - h;
      editor.style.left = `${Math.max(GAP, Math.min(x, vw - w - GAP))}px`;
      editor.style.top = `${Math.max(GAP, Math.min(y, vh - h - GAP))}px`;
      editor.dataset.side = below ? "bottom" : "top";
    }
    place();
    textRef.current?.focus();
    window.addEventListener("resize", place);
    window.addEventListener("scroll", place, { capture: true, passive: true });
    return () => {
      window.removeEventListener("resize", place);
      window.removeEventListener("scroll", place, { capture: true });
    };
  }, [editing]);

  function onEditorToggle(event: ToggleEvent<HTMLDivElement>) {
    if (event.newState !== "closed" || !editing || event.currentTarget.matches(":popover-open")) return;
    const id = editing;
    if (fresh.current === id) {
      fresh.current = null;
      focusNext.current = null;
      commit(notes.filter((n) => n.id !== id));
    } else if (focusNext.current === undefined) {
      const focused = document.activeElement;
      if (!focused || focused === document.body || event.currentTarget.contains(focused)) focusNext.current = id;
    }
    setEditing(null);
  }

  function onLayerClick(event: MouseEvent<HTMLDivElement>) {
    if (!active || event.target !== event.currentTarget) return;
    const r = event.currentTarget.getBoundingClientRect();
    create(((event.clientX - r.left) / r.width) * 100, ((event.clientY - r.top) / r.height) * 100);
  }

  function onPinPointerDown(event: PointerEvent<HTMLButtonElement>, note: AnnotateNote) {
    const layer = layerRef.current;
    if (!layer || event.button !== 0 || !event.isPrimary) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    swallowClick.current = false;
    drag.current = {
      id: note.id,
      pointerId: event.pointerId,
      x0: event.clientX,
      y0: event.clientY,
      x: note.x,
      y: note.y,
      moved: false,
      rect: layer.getBoundingClientRect(),
    };
  }

  function onPinPointerMove(event: PointerEvent<HTMLButtonElement>) {
    const d = drag.current;
    if (!d || d.pointerId !== event.pointerId) return;
    if (!d.moved && Math.hypot(event.clientX - d.x0, event.clientY - d.y0) < DRAG_SLOP) return;
    d.moved = true;
    d.x = clamp(((event.clientX - d.rect.left) / d.rect.width) * 100);
    d.y = clamp(((event.clientY - d.rect.top) / d.rect.height) * 100);
    setDragPos({ id: d.id, x: d.x, y: d.y });
  }

  function onPinPointerUp(event: PointerEvent<HTMLButtonElement>) {
    const d = drag.current;
    if (!d || d.pointerId !== event.pointerId) return;
    drag.current = null;
    if (d.moved) {
      swallowClick.current = true;
      move(d.id, d.x, d.y);
    }
    setDragPos(null);
  }

  function onPinKeyDown(event: KeyboardEvent<HTMLButtonElement>, note: AnnotateNote) {
    const step = event.shiftKey ? 5 : 1;
    const delta: Record<string, [number, number]> = {
      ArrowLeft: [-step, 0],
      ArrowRight: [step, 0],
      ArrowUp: [0, -step],
      ArrowDown: [0, step],
    };
    if (event.key in delta) {
      const [dx, dy] = delta[event.key];
      move(note.id, note.x + dx, note.y + dy);
    } else if (event.key === "Delete" || event.key === "Backspace") {
      remove(note.id);
    } else {
      return;
    }
    event.preventDefault();
    event.stopPropagation();
  }

  const hintId = `${uid}-hint`;
  const editingIndex = notes.findIndex((n) => n.id === editing);

  return (
    <div
      {...rest}
      role="group"
      aria-label={label}
      className={["eq-annotate", className].filter(Boolean).join(" ")}
      data-active={active || undefined}
    >
      <div className="eq-annotate-region">
        {children}
        <div ref={layerRef} className="eq-annotate-layer" onClick={onLayerClick}>
          {notes.map((note, i) => {
            const pos = dragPos?.id === note.id ? dragPos : note;
            const style = { left: `${pos.x}%`, top: `${pos.y}%` };
            if (!active) {
              return (
                <span key={note.id} className="eq-annotate-pin" style={style} title={note.text} aria-hidden="true">
                  {i + 1}
                </span>
              );
            }
            return (
              <button
                key={note.id}
                ref={(el) => {
                  if (el) pins.current.set(note.id, el);
                  else pins.current.delete(note.id);
                }}
                type="button"
                className="eq-annotate-pin"
                style={style}
                aria-label={note.text ? `Note ${i + 1}: ${note.text}` : `Note ${i + 1}, empty`}
                aria-describedby={hintId}
                aria-expanded={editing === note.id}
                data-dragging={dragPos?.id === note.id || undefined}
                onPointerDown={(event) => onPinPointerDown(event, note)}
                onPointerMove={onPinPointerMove}
                onPointerUp={onPinPointerUp}
                onPointerCancel={() => {
                  drag.current = null;
                  setDragPos(null);
                }}
                onClick={() => {
                  if (swallowClick.current) swallowClick.current = false;
                  else open(note);
                }}
                onKeyDown={(event) => onPinKeyDown(event, note)}
              >
                {i + 1}
              </button>
            );
          })}
        </div>
      </div>

      <div className="eq-annotate-footer">
        {notes.length > 0 ? (
          <ol className="eq-annotate-list">
            {notes.map((note, i) => (
              <li key={note.id} className="eq-annotate-item">
                <span className="eq-annotate-number" aria-hidden="true">
                  {i + 1}
                </span>
                <span className="eq-annotate-text" data-empty={!note.text || undefined}>
                  {note.text || "No text yet"}
                </span>
              </li>
            ))}
          </ol>
        ) : (
          active && <p className="eq-annotate-empty">Click anywhere on the design to add a note.</p>
        )}
        {active && (
          <Button ref={addRef} size="s" variant="outline" className="eq-annotate-add" onClick={addAtCentre}>
            Add note
          </Button>
        )}
      </div>

      {active && (
        <>
          <span id={hintId} className="eq-annotate-hint">
            Arrow keys move it, Enter edits, Delete removes.
          </span>
          <div
            ref={editorRef}
            popover="auto"
            role="dialog"
            aria-label={editingIndex >= 0 ? `Note ${editingIndex + 1}` : "Note"}
            className="eq-annotate-editor"
            onToggle={onEditorToggle}
          >
            <Textarea
              ref={textRef}
              id={`${uid}-text`}
              label={editingIndex >= 0 ? `Note ${editingIndex + 1}` : "Note"}
              rows={3}
              value={draft}
              placeholder="What should change here?"
              onChange={(event) => setDraft(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
                  event.preventDefault();
                  save();
                }
              }}
            />
            <div className="eq-annotate-actions">
              <Button size="s" variant="ghost" onClick={() => editing && remove(editing)}>
                Delete
              </Button>
              <Button size="s" disabled={!draft.trim()} onClick={save}>
                Save
              </Button>
            </div>
          </div>
        </>
      )}
    </div>
  );
}
components/equip/annotate.css211 lines
@layer equip {
  .eq-annotate {
    display: grid;
    gap: var(--eq-space-2);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-annotate-region {
    position: relative;
  }

  .eq-annotate-layer {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }

  .eq-annotate[data-active] .eq-annotate-layer {
    pointer-events: auto;
    cursor: crosshair;
  }

  .eq-annotate-pin {
    position: absolute;
    display: grid;
    place-items: center;
    width: 24px;
    height: 24px;
    margin: -12px 0 0 -12px;
    padding: 0;
    border: 2px solid var(--eq-raised);
    border-radius: var(--eq-radius-full);
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    font-variant-numeric: tabular-nums;
    pointer-events: auto;
    user-select: none;
    touch-action: none;
    transition:
      left var(--eq-dur-move) var(--eq-spring),
      top var(--eq-dur-move) var(--eq-spring),
      scale var(--eq-dur-move) var(--eq-spring),
      opacity var(--eq-dur) var(--eq-ease);
  }

  @starting-style {
    .eq-annotate-pin {
      opacity: 0;
      scale: 0.4;
    }
  }

  button.eq-annotate-pin {
    cursor: grab;
  }

  .eq-annotate-pin[aria-expanded="true"] {
    scale: 1.12;
  }

  .eq-annotate-pin[data-dragging] {
    cursor: grabbing;
    scale: 1.12;
    box-shadow: var(--eq-shadow);
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  button.eq-annotate-pin:active:not([data-dragging]) {
    scale: var(--eq-press);
    transition-duration: var(--eq-dur);
    transition-timing-function: var(--eq-ease);
  }

  .eq-annotate-pin:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-annotate-footer {
    display: flex;
    align-items: flex-start;
    gap: var(--eq-space-2);
  }

  .eq-annotate-list {
    flex: 1;
    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-annotate-item {
    display: flex;
    align-items: baseline;
    gap: var(--eq-space-2);
    min-width: 0;
  }

  .eq-annotate-number {
    flex: none;
    display: inline-grid;
    place-items: center;
    min-width: 18px;
    height: 18px;
    padding-inline: 4px;
    border-radius: var(--eq-radius-full);
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    font-variant-numeric: tabular-nums;
  }

  .eq-annotate-text {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .eq-annotate-text[data-empty] {
    color: var(--eq-ink-3);
  }

  .eq-annotate-empty {
    flex: 1;
    margin: 0;
    color: var(--eq-ink-2);
    align-self: center;
  }

  .eq-annotate-add {
    flex: none;
    margin-inline-start: auto;
  }

  .eq-annotate-hint {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .eq-annotate-editor {
    --_from: 0 -4px;

    position: fixed;
    inset: auto;
    margin: 0;
    display: grid;
    gap: var(--eq-space-3);
    width: min(18rem, calc(100vw - 2 * var(--eq-space-2)));
    padding: var(--eq-space-4);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-l);
    background: var(--eq-raised);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    box-shadow: var(--eq-shadow);
    color: var(--eq-ink);
    opacity: 0;
    scale: 0.96;
    translate: var(--_from);
    transform-origin: top center;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease),
      display var(--eq-dur) allow-discrete,
      overlay var(--eq-dur) allow-discrete;
  }

  .eq-annotate-editor:not(:popover-open) {
    display: none;
  }

  .eq-annotate-editor[data-side="top"] {
    --_from: 0 4px;
    transform-origin: bottom center;
  }

  .eq-annotate-editor:popover-open {
    opacity: 1;
    scale: 1;
    translate: 0 0;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring),
      display var(--eq-dur) allow-discrete,
      overlay var(--eq-dur) allow-discrete;
  }

  @starting-style {
    .eq-annotate-editor:popover-open {
      opacity: 0;
      scale: 0.96;
      translate: var(--_from);
    }
  }

  .eq-annotate-actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--eq-space-2);
  }
}

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