How to Build an In-Article Editorial Annotation Layer in Next.js with Vanilla JavaScript and CSS | Build with Matija
How to Build an In-Article Editorial Annotation Layer in Next.js with Vanilla JavaScript and CSS
How to Build an In-Article Editorial Annotation Layer in Next.js with Vanilla JavaScript and CSS
Step-by-step guide to building a zero-dependency, voice-ready editorial annotation popover with native browser…
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Last week, I was reviewing a newly published technical article on our site when I noticed a couple of sentences that needed restructuring and an updated code snippet. The standard routine for this is frustratingly clunky. You copy the excerpt, tab over to Linear, GitHub, or Notion, paste the quote, write your suggestion, and switch back to continue reading. If you want to dictate a quick thought using macOS or iOS speech-to-text, context switching completely derails your momentum.
I wanted an editorial experience that felt as immediate as reading: highlight any paragraph or sentence directly on the live page, have a sleek card appear right above the text with the cursor already focused in a text area, and dictate a voice note that saves directly to our headless CMS alongside the exact quote and heading anchor.
Most tutorials would reach for heavy UI primitive libraries like Popper, Floating UI, or full modal packages for this. But pulling in hundreds of kilobytes of dependencies when evaluating headless UI primitives and choosing when to own UI behavior shows that zero-dependency native browser APIs are often cleaner and lighter. In this guide, I will show you how to build a production-ready, zero-dependency editorial annotation layer in Next.js using native browser APIs and vanilla CSS.
1. Scoping the Article Container and Protecting Public Readers
Before attaching any DOM listeners, we need to solve two fundamental requirements. First, regular readers should never experience extra JavaScript execution or layout shifts. Second, text selection events must be scoped strictly to the article body prose, ignoring navigation headers, sidebars, and footers.
We start by marking the article container in our page template with a custom data attribute and mounting a client-side layer that checks our session cookie on mount.
This component performs a quick, lightweight authorization check against an endpoint that reads our admin session cookie, similar to how we protect internal routes in our Next.js internationalization architecture and edge middleware layers. If the visitor is not an authenticated editor, the component immediately returns null. No event listeners are attached, no extra markup enters the DOM, and public page performance remains untouched. In the article layout, we simply place data-editorial-article="true" on the <article> tag so our selection listener knows its boundary.
Now that we have secured the perimeter and established our target container, we can listen for native text selections and extract contextual references.
2. Capturing Native Selections and Traversing Heading Anchors
When an editor highlights a sentence, storing the raw text alone is not enough. You also need to know which section of the article it belongs to, and what surrounding paragraph context enveloped it.
To accomplish this without third-party libraries, we listen to the native selectionchange event on the document. When a selection occurs within our article container, we traverse the DOM backward to find the nearest preceding heading.
This helper starts at the selection node and climbs up the DOM tree, examining preceding siblings at each level. If a sibling or its child matches an h1, h2, h3, or h4 tag, it extracts that title. This gives us an automatic section anchor, so when our editorial team reviews notes later, we know immediately whether the note applies to the introduction, a code walkthrough section, or the conclusion.
Next, we wire this helper into our selection listener with a debounce to prevent jitter during mouse dragging.
In this block, window.getSelection() retrieves the highlighted text and its Range. We verify that the common ancestor container of the selection lives inside our article boundary. If it does, we extract the selected prose, sample up to 300 characters of the parent paragraph for context, and capture the bounding client rectangle.
Once we have the rectangle coordinates, we must calculate where to position our floating interface without allowing it to collide with viewport boundaries.
3. Viewport Geometry and Collision Clamping
Floating popovers often break when text is highlighted near the edge of a screen or at the very top of a viewport. Without an external library handling collision detection, we can implement precise coordinate math in vanilla JavaScript.
We calculate the horizontal center of the selection, add the current page scroll offset, and clamp the value so the card never clips past the edge of the viewport.
This math solves three common positioning bugs in a few lines of code. Subtracting half the card width centers the popover above the selected text. Clamping the left coordinate between sixteen pixels and the remaining screen width guarantees that the card never overflows off the left or right edges on mobile screens or split windows. Finally, if the selection is within 140 pixels of the top edge of the browser viewport, our flag flips vertical translation so the card opens downward rather than off the top of the screen.
With our positioning math in place, we can construct the actual annotation card optimized for rapid voice dictation.
4. Building the Voice-Ready Annotation Card with Vanilla CSS
When you highlight text to dictate a thought, you should not have to click an extra input to start speaking. The card should appear, immediately focus a native textarea, display the quoted passage, and allow you to hit your keyboard dictation shortcut or type your feedback right away.
By building the annotation card as an isolated vertical slice with no external dependencies, we follow the pattern of shipping reusable vertical slices that can be embedded into any article or documentation layout without leaking styles or bundle weight.
Here is the implementation of our annotation card, styled with pure vanilla CSS and subtle frosted glassmorphism.
This card is built using standard inline styles and native HTML elements. On mount, textareaRef.current.focus() immediately captures system focus so pressing the microphone key on your device begins speech dictation right away. We also include a keyboard listener for Command or Control plus Enter to submit without reaching for the mouse, and Escape to dismiss.
The final step is receiving this payload on our server and persisting it to our CMS.
5. Persisting Annotations to the Headless CMS
When our frontend submits the payload, our Next.js Route Handler needs to verify the admin session and create an annotation document. In our setup, we store editorial feedback as a dedicated schema in Sanity, linking it directly to the target post document.
The route handler checks our session headers using our authentication layer. Once confirmed, it writes an articleFeedback document to Sanity with status: 'open'. The document captures the exact quoted text, the surrounding paragraph context, the preceding heading title, the author's email, and the dictated note.
Because this is a structured document in our CMS, our developers or AI agents can query open feedback using a simple CLI script (pnpm content feedback <slug>), review changes alongside our custom Payload admin interfaces, and trigger automated CMS vector syncs once content revisions are approved.
Conclusion
Editorial feedback is often slow and fragmented because the tools we use to read live articles are completely separated from the tools we use to request changes. By listening to native DOM text selections and reading sibling headings in an AST traversal, we built an on-page editorial layer that captures voice notes right where the reading happens.
We avoided large third-party UI libraries entirely. Pure vanilla JavaScript handles the selection boundary detection, parent paragraph context sniffs, and screen clamping math, while standard vanilla CSS delivers a responsive, frosted-glass interface that stays out of the way of regular visitors.
With this foundation in place, you can adapt the backend persistence to whatever CMS or database your application uses, giving your editorial team a frictionless, voice-friendly review workflow.