@binary1702/annotate
v0.6.5
Published
Figma-style comments attached to live DOM elements — plus private review links, a Postgres store and a drop-in Next.js integration. Visual website annotation / client feedback for React.
Maintainers
Readme
@binary1702/annotate
Figma-style comments, attached to live DOM elements.
Turn on Review Mode, point at anything on your site, click, leave a comment. A numbered pin stays attached to that element; anyone can open it, reply, and resolve it. Turn Review Mode off and the site behaves exactly as before.
- Works with any React app — Next.js, Vite, CRA, Remix. No framework imports.
- You own auth, identity and storage. The library talks to a five-method adapter.
- Zero DOM mutation: outlines and pins are drawn on a portal layer, never on your elements.
- Zero overhead while disabled: no listeners, no fetches, no DOM.
npm install @binary1702/annotateQuick Start (Next.js + Postgres)
npm install @binary1702/annotate @neondatabase/serverless
npx @binary1702/annotate initThe CLI will:
- Connect to your Postgres database
- Create the
annotationstable - Generate reviewer tokens
- Write everything to
.env.local
Then wire up these files:
// src/review.ts
import { neon } from "@neondatabase/serverless";
import { postgresStore } from "@binary1702/annotate/postgres";
const sql = neon(process.env.DATABASE_URL!);
export const store = postgresStore((t, p) => sql(t, p), process.env.ANNOTATE_PROJECT_ID!);// src/middleware.ts
import { createReviewProxy } from "@binary1702/annotate/next/proxy";
export const middleware = createReviewProxy();
export const config = { matcher: ["/((?!api|_next|.*\\.[a-zA-Z0-9]+$).*)"] };// src/app/api/review/[...route]/route.ts
import { createReviewHandlers } from "@binary1702/annotate/next/handlers";
import { store } from "@/review";
export const { GET, POST } = createReviewHandlers({ store });// src/app/layout.tsx
import "@binary1702/annotate/styles.css";
import { ReviewMode } from "@binary1702/annotate/next";
// …
<body><ReviewMode>{children}</ReviewMode></body>// src/app/review/page.tsx — owner's inbox (optional)
import { createReviewInbox } from "@binary1702/annotate/next/inbox";
import { store } from "@/review";
export const dynamic = "force-dynamic";
export default createReviewInbox({ store });Send each reviewer their link: https://your-site.com?r=<token>. Done.
How the pieces fit
/?r=<token> ─▶ next/proxy ─▶ httpOnly cookie ─▶ <ReviewMode> asks /api/review/me
401 → plain site (public visitors)
200 → overlay mounts as "Carol"
click element → comment ─▶ next/handlers ─▶ server (identity from cookie, never payload)
└─▶ postgres store (project_id scoped)
/review ─▶ next/inbox (admin tokens) ─▶ every thread, by page, filter by reviewer/statusPages stay statically rendered — identity resolves client-side after load.
Subpath map
| Import | Runs | Contains |
| --- | --- | --- |
| @binary1702/annotate | client | overlay, provider, hook, memory adapter (framework-free) |
| @binary1702/annotate/server | server, edge | createIdentity, createReviewService, createReviewApi (Fetch Request → Response), createMemoryStore |
| @binary1702/annotate/postgres | server | postgresStore(query, projectId) — bring any driver; schema.sql alongside |
| @binary1702/annotate/next | client | <ReviewMode>, <ReviewBar>, createFetchAdapter |
| @binary1702/annotate/next/handlers | server | createReviewHandlers |
| @binary1702/annotate/next/proxy | edge | createReviewProxy |
| @binary1702/annotate/next/inbox | server | createReviewInbox |
Not on Next? createReviewApi({ store }).handle(request) is a plain Fetch handler — mount it in Remix, Hono, Bun or a Worker,
and use createIdentity() + createFetchAdapter() with your own provider.
Smallest possible integration
import { AnnotationProvider, createMemoryAnnotationAdapter } from "@binary1702/annotate";
import "@binary1702/annotate/styles.css";
const adapter = createMemoryAnnotationAdapter(); // swap for your backend adapter (below)
export function Root() {
const [reviewMode, setReviewMode] = useState(false);
return (
<AnnotationProvider
enabled={reviewMode}
onEnabledChange={setReviewMode}
currentUser={{ id: "u1", name: "Gladys" }}
adapter={adapter}
>
<button onClick={() => setReviewMode((v) => !v)} data-annotation-ui="">
Review Mode
</button>
<App />
</AnnotationProvider>
);
}That's the whole thing. The overlay is mounted automatically by the provider.
Then, in your UI, tag the elements clients are most likely to comment on:
<button data-annotation-id="homepage.hero.cta">Start a Project</button>How it fits together
Your app @binary1702/annotate
────────────────────────────── ─────────────────────────────────────────
auth / current user ───────────▶ currentUser
review-mode toggle ───────────▶ enabled (controlled)
router pathname ───────────▶ pageId
backend / DB ◀── adapter ─ list / create / reply / resolve / reopen
│
├─ hover outline, click interception
├─ target identification + relocation
├─ pins, composer, thread UI
└─ positioning, scroll/resize tracking1. Provider
<AnnotationProvider
adapter={adapter} // required — see "Persistence"
currentUser={user} // required — { id, name, avatarUrl? }
enabled={reviewMode} // controlled; omit for uncontrolled + defaultEnabled
onEnabledChange={setReviewMode} // called when the package asks to toggle (useAnnotations().setEnabled)
pageId={pathname} // or getPageId={() => window.location.pathname}
showResolved={false} // keep resolved pins visible (muted) instead of hiding them
renderOverlay // default true; set false and render <AnnotationOverlay /> yourself
ignore={(el) => el.closest(".no-comments") !== null} // optional extra exclusions
>enabled is controlled-first. Review Mode is usually tied to a permission or a URL flag your app already owns, so the flag lives in your state and the provider mirrors it. useAnnotations().setEnabled exists for convenience (e.g. a toolbar inside the annotated app) and simply calls onEnabledChange; omit enabled entirely and the provider manages it for you.
Page identity. Annotations belong to a page string. Pass whatever your router gives you:
// Next.js (app router)
"use client";
import { usePathname } from "next/navigation";
const pathname = usePathname();
<AnnotationProvider pageId={pathname} …>When pageId changes the provider reloads that page's annotations. Nothing from Next.js is imported by the package.
2. Review Mode
While enabled is true:
- Hovering shows a thin outline around the element under the pointer.
- Clicking is intercepted in the capture phase — buttons don't fire, links don't navigate, your React handlers never run — and a comment composer opens next to the element.
Esccancels,⌘/Ctrl+Entersubmits. - Saved comments appear as numbered pins on the element's top-right corner. Pins follow scroll and resize.
- Clicking a pin (or the element again) opens the thread: reply, resolve, reopen.
- Elements marked
data-annotation-uikeep working normally — put it on your Review Mode toggle. <html data-annotation-mode>is set so you can style your own chrome while reviewing.
While enabled is false the overlay is unmounted: no listeners, no adapter calls, no DOM.
One thread per element. Clicking an element that already has a thread opens it rather than creating a second pin. Simpler for reviewers, and the pin number becomes a stable reference ("see #3").
3. data-annotation-id — please use it
The hard problem in this library is finding the element again after a re-render, a deploy, or a copy change. Targets are stored with four levels of fallback:
| Priority | Stored as | Survives… |
| --- | --- | --- |
| 1 | data-annotation-id="homepage.hero.cta" | anything short of removing the attribute |
| 2 | generated selector (#id, [data-testid], [aria-label], a[href], [role]…) | structural changes, not attribute changes |
| 3 | DOM path (#hero > div:nth-of-type(2) > button:nth-of-type(1)) | only cosmetic changes |
| 4 | tagName + textSnippet + ariaLabel + last known rect (heuristic recovery) | path changes, if the text is intact |
Levels 2–4 are best effort. Level 1 is the only one that is yours — it means a comment on the hero CTA is still on the hero CTA after the marketing team rewrites the copy, the designer restructures the section, and the engineer swaps the component library. Tag the things clients comment on: headings, CTAs, cards, nav items, forms.
// plain attribute (recommended — nothing to import)
<button data-annotation-id="homepage.hero.cta">Start a Project</button>
// spread helper
import { annotationTarget } from "@binary1702/annotate";
<button {...annotationTarget("homepage.hero.cta")}>Start a Project</button>
// wrapper (child must forward unknown props to its root element)
import { Annotatable } from "@binary1702/annotate";
<Annotatable id="homepage.hero.cta">
<Button>Start a Project</Button>
</Annotatable>Use dotted, page-scoped names (pricing.plans.pro.cta) and never reuse an id on the same page — the resolver requires exactly one match.
4. Persistence — the adapter
The library never touches a database. Implement five methods:
import type { AnnotationAdapter } from "@binary1702/annotate";
export const adapter: AnnotationAdapter = {
list: (page) => api.get(`/annotations?page=${encodeURIComponent(page)}`),
create: (input) => api.post("/annotations", input),
// input: { page, target, body, author } → returns Annotation (with server id)
reply: (annotationId, input, ctx) => api.post(`/annotations/${annotationId}/comments`, input),
// input: { body, author } → returns AnnotationComment
resolve: (annotationId, ctx) => api.post(`/annotations/${annotationId}/resolve`),
reopen: (annotationId, ctx) => api.post(`/annotations/${annotationId}/reopen`),
};ctx is { user, page } for the acting user, in case your API needs it. Errors thrown by any method are shown inline in the UI (and resolve/reopen are rolled back).
Example backend (pseudocode)
Two tables, nothing clever:
create table annotations (
id text primary key,
page text not null,
target jsonb not null, -- AnnotationTarget, stored verbatim
status text not null default 'open',
created_at timestamptz not null default now(),
updated_at timestamptz,
created_by jsonb not null, -- AnnotationUser
resolved_at timestamptz,
resolved_by jsonb,
metadata jsonb
);
create index on annotations (page);
create table annotation_comments (
id text primary key,
annotation_id text references annotations(id) on delete cascade,
body text not null,
created_at timestamptz not null default now(),
created_by jsonb not null
);// GET /annotations?page=/pricing
rows = select * from annotations where page = $page order by created_at
each row.comments = select * from annotation_comments where annotation_id = row.id order by created_at
return rows // shape === Annotation[]
// POST /annotations body: CreateAnnotationInput
assert session.user.id === body.author.id // identity is yours to enforce
annotation = insert annotations {...}
comment = insert annotation_comments { body: body.body, created_by: body.author }
return { ...annotation, comments: [comment] }
// POST /annotations/:id/comments body: CreateCommentInput
return insert annotation_comments { annotation_id: id, body, created_by: body.author }
// POST /annotations/:id/resolve | /reopen
update annotations set status = 'resolved' | 'open', resolved_at = now() | null, resolved_by = user | nullFor Next.js the natural home is a route handler (app/api/annotations/route.ts) or server actions; the adapter is then a thin fetch wrapper living in a client component.
Memory adapter (dev / tests)
const adapter = createMemoryAnnotationAdapter({ latency: 250, initial: [] });
adapter.dump(); adapter.clear();Data lives in a Map for the lifetime of the page. It is for demos and tests, not persistence.
5. Hook
const {
enabled, setEnabled,
page,
annotations, openAnnotations, resolvedAnnotations,
selectedAnnotation, select, // open a thread programmatically: select(id) / select(null)
refresh, loading, error,
resolve, reopen,
} = useAnnotations();Handy for a "3 open comments" badge on your Review Mode toggle.
Styling
The stylesheet is scoped under the portal root and driven by CSS variables. Override on :root (or on [data-annotation-root]):
:root {
--annotation-accent: #4f46e5;
--annotation-accent-contrast: #fff;
--annotation-bg: #fff;
--annotation-text: #111827;
--annotation-muted: #6b7280;
--annotation-border: #e5e7eb;
--annotation-radius: 10px;
--annotation-shadow: 0 10px 30px rgba(0,0,0,.14);
--annotation-font: system-ui, sans-serif;
--annotation-z-index: 2147483000;
--annotation-resolved: #10b981;
--annotation-danger: #dc2626;
}No Tailwind, no runtime CSS-in-JS. Skip the stylesheet entirely and target the .bna-* classes if you want a bespoke look.
SSR / Next.js
Safe to import anywhere. window/document are only touched inside effects and event handlers; on the server the provider renders its children and nothing else. The provider must live in a client component ("use client") because it holds state, like any context provider.
Data model
interface Annotation {
id: string;
page: string;
target: AnnotationTarget;
status: "open" | "resolved";
createdAt: string; // ISO-8601
updatedAt?: string;
createdBy: AnnotationUser;
resolvedAt?: string;
resolvedBy?: AnnotationUser;
comments: AnnotationComment[]; // [0] is the original comment
metadata?: Record<string, unknown>; // yours; the library never reads it
}
interface AnnotationTarget {
annotationId?: string; // data-annotation-id
selector?: string;
domPath?: string;
tagName: string;
textSnippet?: string;
ariaLabel?: string;
rect?: { x: number; y: number; width: number; height: number }; // document coords
}
interface AnnotationComment { id: string; body: string; createdAt: string; createdBy: AnnotationUser }
interface AnnotationUser { id: string; name: string; avatarUrl?: string }Everything is plain JSON.
Accessibility
Pins and card controls are real <button>s with labels; the composer autofocuses; Esc closes; focus returns to where it was. In Review Mode, Enter/Space on a focused page element selects it for commenting instead of activating it.
Development
npm install
npm run dev # demo at http://localhost:5173
npm test
npm run typecheck
npm run build # dist/index.js + index.d.ts + styles.cssCLI
npx @binary1702/annotate init # interactive setup
npx @binary1702/annotate schema # print schema.sql to stdoutinit prompts for DATABASE_URL, connects, creates the table, generates reviewer tokens, and writes .env.local.
Changelog
- 0.3.0 — CLI (
npx @binary1702/annotate init) for interactive setup with automatic schema migration. - 0.2.2 — "Leave review mode" link in the bar (
?r=off). - 0.2.1 —
<ReviewMode>remembers the Commenting on/off choice across pages and reloads; memory store warns when used outside tests. - 0.2.0 —
server,postgresandnext/*subpaths: private review links, Postgres store, drop-in Next.js integration, inbox. - 0.1.0 — overlay, provider, adapter contract.
Deliberately not in V1
Screenshots, drawing, video, attachments, assignees, priorities, Jira/GitHub/Slack integrations, AI summaries, real-time sync, browser extension, mobile SDK. The data model leaves room (metadata, adapter methods are additive) but none of it is built.
License
MIT © Binary 1702 LLC
