BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. 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…

23rd September 2026·Updated on:23rd September 2026·MŽMatija Žiberna·
Next.js
How to Build an In-Article Editorial Annotation Layer in Next.js with Vanilla JavaScript and CSS

⚡ 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.

No spam. Unsubscribe anytime.

Related Posts:

  • •Headless UI Primitives: When to Own UI Behavior Today
  • •Stop Starting From Scratch: How Vertical Slices Build Reusable Frontends
  • •Payload CMS @payloadcms/ui Component Reference
  • •OpenAI Vector Store File Sync with Node.js: Keep CMS Content Updated
📄View markdown version
0

Comments

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Headless UI Primitives: When to Own UI Behavior Today
Headless UI Primitives: When to Own UI Behavior Today

9th September 2026

Stop Starting From Scratch: How Vertical Slices Build Reusable Frontends
Stop Starting From Scratch: How Vertical Slices Build Reusable Frontends

21st September 2026

Payload CMS @payloadcms/ui Component Reference
Payload CMS @payloadcms/ui Component Reference

13th November 2025

OpenAI Vector Store File Sync with Node.js: Keep CMS Content Updated
OpenAI Vector Store File Sync with Node.js: Keep CMS Content Updated

15th October 2025

Contents

  • 1. Scoping the Article Container and Protecting Public Readers
  • 2. Capturing Native Selections and Traversing Heading Anchors
  • 3. Viewport Geometry and Collision Clamping
  • 4. Building the Voice-Ready Annotation Card with Vanilla CSS
  • 5. Persisting Annotations to the Headless CMS
  • Conclusion
On this page:
  • 1. Scoping the Article Container and Protecting Public Readers
  • 2. Capturing Native Selections and Traversing Heading Anchors
  • 3. Viewport Geometry and Collision Clamping
  • 4. Building the Voice-Ready Annotation Card with Vanilla CSS
  • 5. Persisting Annotations to the Headless CMS
Build with Matija logotip

Build with Matija

Complex B2B websites, headless CMS platforms, AI workflows, and internal systems designed and built with Next.js and Payload CMS.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies
  • About Matija

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•Vse pravice pridržane•Politika zasebnosti•Pogoji uporabe
BuildWithMatija
Get In Touch

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.

typescript
// File: src/components/blog/editorial/EditorialFeedbackLayer.tsx
'use client'

import React, { useState, useEffect, useRef } from 'react'

interface EditorialFeedbackLayerProps {
  postId: string
  postSlug: string
  postTitle: string
}

export function EditorialFeedbackLayer({
  postId,
  postSlug,
  postTitle,
}: EditorialFeedbackLayerProps) {
  const [isAdmin, setIsAdmin] = useState<boolean | null>(null)

  useEffect(() => {
    let isMounted = true

    async function checkSession() {
      try {
        const res = await fetch('/api/editorial/session')
        const data = await res.json()
        if (isMounted) {
          setIsAdmin(Boolean(data.authenticated && data.isAdmin))
        }
      } catch {
        if (isMounted) {
          setIsAdmin(false)
        }
      }
    }

    checkSession()
    return () => {
      isMounted = false
    }
  }, [])

  if (!isAdmin) {
    return null
  }

  return (
    <div className="editorial-feedback-root" data-editorial-active="true">
      {/* Editorial UI components render here */}
    </div>
  )
}

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.

typescript
// File: src/components/blog/editorial/EditorialFeedbackLayer.tsx
function findPrecedingHeading(node: Node | null): string {
  let current: Node | null = node

  while (current && current !== document.body) {
    let prev: Node | null = current.previousSibling
    while (prev) {
      if (prev.nodeType === Node.ELEMENT_NODE) {
        const el = prev as HTMLElement
        if (/^H[1-4]$/i.test(el.tagName)) {
          return el.innerText.trim()
        }
        const nestedHeading = el.querySelector('h1, h2, h3, h4')
        if (nestedHeading) {
          return (nestedHeading as HTMLElement).innerText.trim()
        }
      }
      prev = prev.previousSibling
    }
    current = current.parentNode
  }
  return ''
}

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.

typescript
// File: src/components/blog/editorial/EditorialFeedbackLayer.tsx
export interface SelectionState {
  text: string
  context: string
  anchorHeading: string
  x: number
  y: number
  positionAbove: boolean
}

// Inside EditorialFeedbackLayer component:
const [activeSelection, setActiveSelection] = useState<SelectionState | null>(null)
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null)

useEffect(() => {
  if (!isAdmin) return

  const handleSelectionChange = () => {
    if (selectionTimeoutRef.current) {
      clearTimeout(selectionTimeoutRef.current)
    }

    selectionTimeoutRef.current = setTimeout(() => {
      const selection = window.getSelection()
      if (!selection || selection.isCollapsed) {
        setActiveSelection(null)
        return
      }

      const text = selection.toString().trim()
      if (text.length < 2 || selection.rangeCount === 0) {
        setActiveSelection(null)
        return
      }

      const range = selection.getRangeAt(0)
      const articleContainer = document.querySelector('[data-editorial-article]')

      if (!articleContainer || !articleContainer.contains(range.commonAncestorContainer)) {
        setActiveSelection(null)
        return
      }

      const rect = range.getBoundingClientRect()
      if (rect.width === 0 && rect.height === 0) {
        setActiveSelection(null)
        return
      }

      const anchorHeading = findPrecedingHeading(range.startContainer)
      const parentElem = (
        range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
          ? range.commonAncestorContainer
          : range.commonAncestorContainer.parentElement
      ) as HTMLElement | null

      const context = parentElem ? parentElem.innerText.slice(0, 300) : text

      const x = rect.left + rect.width / 2 + window.scrollX
      const positionAbove = rect.top >= 140
      const y = positionAbove ? rect.top + window.scrollY : rect.bottom + window.scrollY

      setActiveSelection({
        text,
        context,
        anchorHeading,
        x,
        y,
        positionAbove,
      })
    }, 120)
  }

  document.addEventListener('selectionchange', handleSelectionChange)
  return () => {
    document.removeEventListener('selectionchange', handleSelectionChange)
    if (selectionTimeoutRef.current) {
      clearTimeout(selectionTimeoutRef.current)
    }
  }
}, [isAdmin])

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.

typescript
// File: src/components/blog/editorial/EditorialAnnotationCard.tsx
interface CardPositionProps {
  x: number
  y: number
  positionAbove: boolean
  cardWidth?: number
}

export function computeCardCoordinates({
  x,
  y,
  positionAbove,
  cardWidth = 380,
}: CardPositionProps) {
  const padding = 16
  const screenWidth = typeof window !== 'undefined' ? window.innerWidth : 1024
  
  // Center card over selection, but clamp within viewport boundaries
  const rawLeft = x - cardWidth / 2
  const clampedLeft = Math.max(padding, Math.min(rawLeft, screenWidth - cardWidth - padding))

  // Determine vertical offset based on whether popover renders above or below
  const verticalOffset = positionAbove ? -12 : 12

  return {
    left: `${clampedLeft}px`,
    top: `${y + verticalOffset}px`,
    transform: positionAbove ? 'translateY(-100%)' : 'translateY(0)',
  }
}

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.

typescript
// File: src/components/blog/editorial/EditorialAnnotationCard.tsx
'use client'

import React, { useState, useEffect, useRef } from 'react'
import { SelectionState } from './EditorialFeedbackLayer'

interface EditorialAnnotationCardProps {
  postId: string
  postSlug: string
  postTitle: string
  selection: SelectionState
  onClose: () => void
  onSaved: () => void
}

const CATEGORIES = [
  { value: 'editorial-note', label: 'Note' },
  { value: 'rewrite', label: 'Rewrite' },
  { value: 'fact-check', label: 'Fact-Check' },
  { value: 'typo', label: 'Typo' },
] as const

export function EditorialAnnotationCard({
  postId,
  postSlug,
  postTitle,
  selection,
  onClose,
  onSaved,
}: EditorialAnnotationCardProps) {
  const [notes, setNotes] = useState('')
  const [category, setCategory] = useState<string>('rewrite')
  const [isSubmitting, setIsSubmitting] = useState(false)
  const textareaRef = useRef<HTMLTextAreaElement>(null)
  const cardRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.focus()
    }
  }, [])

  const handleSubmit = async () => {
    if (!notes.trim() || isSubmitting) return
    setIsSubmitting(true)

    try {
      const res = await fetch('/api/editorial/feedback', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          postId,
          postSlug,
          postTitle,
          selectedText: selection.text,
          context: selection.context,
          anchorHeading: selection.anchorHeading,
          notes: notes.trim(),
          category,
        }),
      })

      if (res.ok) {
        onSaved()
        onClose()
      }
    } finally {
      setIsSubmitting(false)
    }
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
      e.preventDefault()
      handleSubmit()
    } else if (e.key === 'Escape') {
      onClose()
    }
  }

  return (
    <div
      ref={cardRef}
      style={{
        position: 'absolute',
        width: '380px',
        maxWidth: 'calc(100vw - 32px)',
        zIndex: 9999,
        background: 'rgba(18, 18, 22, 0.94)',
        backdropFilter: 'blur(16px)',
        WebkitBackdropFilter: 'blur(16px)',
        border: '1px solid rgba(255, 255, 255, 0.12)',
        borderRadius: '12px',
        boxShadow: '0 20px 48px rgba(0, 0, 0, 0.5)',
        padding: '14px',
        color: '#f4f4f5',
        fontFamily: 'inherit',
      }}
    >
      <div style={{ fontSize: '11px', color: '#a1a1aa', marginBottom: '8px', fontStyle: 'italic' }}>
        &ldquo;{selection.text.slice(0, 90)}{selection.text.length > 90 ? '...' : ''}&rdquo;
      </div>

      <div style={{ display: 'flex', gap: '6px', marginBottom: '10px' }}>
        {CATEGORIES.map((cat) => (
          <button
            key={cat.value}
            type="button"
            onClick={() => setCategory(cat.value)}
            style={{
              fontSize: '11px',
              padding: '3px 8px',
              borderRadius: '6px',
              border: 'none',
              cursor: 'pointer',
              background: category === cat.value ? '#3b82f6' : 'rgba(255, 255, 255, 0.08)',
              color: category === cat.value ? '#ffffff' : '#d4d4d8',
            }}
          >
            {cat.label}
          </button>
        ))}
      </div>

      <textarea
        ref={textareaRef}
        value={notes}
        onChange={(e) => setNotes(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Dictate or type editorial feedback... (Cmd+Enter to save)"
        rows={3}
        style={{
          width: '100%',
          boxSizing: 'border-box',
          background: 'rgba(0, 0, 0, 0.4)',
          border: '1px solid rgba(255, 255, 255, 0.1)',
          borderRadius: '8px',
          color: '#ffffff',
          padding: '8px 10px',
          fontSize: '13px',
          resize: 'vertical',
          outline: 'none',
        }}
      />

      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '10px' }}>
        <button
          type="button"
          onClick={onClose}
          style={{
            background: 'transparent',
            border: 'none',
            color: '#a1a1aa',
            fontSize: '12px',
            cursor: 'pointer',
          }}
        >
          Cancel
        </button>
        <button
          type="button"
          onClick={handleSubmit}
          disabled={isSubmitting || !notes.trim()}
          style={{
            background: '#2563eb',
            border: 'none',
            borderRadius: '6px',
            color: '#ffffff',
            padding: '6px 14px',
            fontSize: '12px',
            fontWeight: 500,
            cursor: isSubmitting ? 'not-allowed' : 'pointer',
            opacity: isSubmitting || !notes.trim() ? 0.6 : 1,
          }}
        >
          {isSubmitting ? 'Saving...' : 'Save Feedback'}
        </button>
      </div>
    </div>
  )
}

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.

typescript
// File: src/app/api/editorial/feedback/route.ts
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { freshClient } from '@/lib/sanity/client'

export async function POST(request: Request) {
  try {
    const payload = await getPayload({ config: configPromise })
    const { user } = await payload.auth({ headers: request.headers })

    if (!user) {
      return NextResponse.json(
        { error: 'Unauthorized. Admin credentials required.' },
        { status: 401 }
      )
    }

    const body = await request.json()
    const {
      postId,
      postSlug,
      postTitle,
      selectedText,
      context,
      anchorHeading,
      notes,
      category,
    } = body

    if (!postSlug || !selectedText || !notes) {
      return NextResponse.json(
        { error: 'Missing required feedback fields.' },
        { status: 400 }
      )
    }

    const doc = await freshClient.create({
      _type: 'articleFeedback',
      post: postId ? { _type: 'reference', _ref: postId } : undefined,
      articleSlug: postSlug,
      articleTitle: postTitle || postSlug,
      selectedText,
      context: context || selectedText,
      anchorHeading: anchorHeading || '',
      notes,
      category: category || 'editorial-note',
      status: 'open',
      authorEmail: user.email || 'admin',
      createdAt: new Date().toISOString(),
    })

    return NextResponse.json({ success: true, feedback: doc })
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to record editorial feedback.' },
      { status: 500 }
    )
  }
}

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.

Thanks,
Matija

Comments