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
Team plan
Upgrade14 per seat
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.
1 note ready to send with the reviewCode
"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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the reviewed region, such as "Checkout page, desktop". |
notes | { id: string; x: number; y: number; text: string }[] | none | Controlled notes. x and y are percentages of the region, so pins survive resizing. |
defaultNotes | AnnotateNote[] | none | Starting notes when uncontrolled. |
onNotesChange | (notes: AnnotateNote[]) => void | none | Called with the full list after every add, move, edit and delete. |
active | boolean | true | When false the region behaves normally, pins are read-only markers and only the list remains. |
children | ReactNode | none | The design under review. |
...rest | ComponentProps<"div"> | none | Native attributes for the root, which is a labelled group. |
Anatomy
| root | .eq-annotate | |
|---|---|---|
| region | .eq-annotate-region | Wraps the children and positions the pins. |
| layer | .eq-annotate-layer | Covers the design while active and catches the click that adds a pin. |
| pin | .eq-annotate-pin | A button while active, a hidden marker when read-only. |
| footer | .eq-annotate-footer | |
| list | .eq-annotate-list | Every note in order, readable without finding pins. |
| item | .eq-annotate-item | |
| number | .eq-annotate-number | |
| text | .eq-annotate-text | |
| empty | .eq-annotate-empty | Shown while active with no notes. |
| add | .eq-annotate-add | Places a pin at the centre, the keyboard way to add a note. |
| hint | .eq-annotate-hint | Visually hidden key help, the description of every pin. |
| editor | .eq-annotate-editor | A popover in the top layer, so an overflow: hidden region never clips it. |
| actions | .eq-annotate-actions |
Keyboard
| EnterSpace | On Add note, places a pin at the centre and opens its editor. |
|---|---|
| EnterSpace | On a pin, opens its editor. |
| ArrowLeftArrowRightArrowUpArrowDown | On a pin, moves it by 1% of the region. |
| ShiftArrow keys | On a pin, moves it by 5%. |
| DeleteBackspace | On a pin, removes it and moves focus to the next pin or Add note. |
| CtrlEnter | In the editor, saves the note. Cmd on macOS. |
| Escape | Closes 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);
}
}