Text

Annotated Text

Prose with notes attached — marked phrases carry a numbered marker, the note opens on hover or focus, and every note is also listed in full under the passage.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Consumers name a token, never a colour: re-theming the app re-themes every
 * annotation with it, and dark mode costs nothing.
 */
const TONES = {
  primary: "var(--primary)",
  "chart-1": "var(--chart-1)",
  "chart-2": "var(--chart-2)",
  "chart-3": "var(--chart-3)",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/annotated-text.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AnnotatedText" component (React and a cn()
class merger only — no positioning library, no animation library, no icons): a
passage of prose whose marked phrases carry a numbered marker, with the note
revealed in a floating panel on hover, focus or tap, and listed in full beneath
the passage so it is reachable without pointing at anything.

Contract
- export const AnnotatedText = React.forwardRef<HTMLDivElement, AnnotatedTextProps>;
  AnnotatedTextProps extends React.HTMLAttributes<HTMLDivElement> minus children.
- Props: text (the passage as one plain string — every offset indexes it),
  annotations: TextAnnotation[], notes: "list" | "hidden" = "list",
  notesLabel = "Notes", openDelay = 140, closeDelay = 180, noteWidth = 288.
  className and the remaining native props land on the root.
- TextAnnotation = { id: string; quote?: string; occurrence?: number (1-based,
  default 1); range?: [number, number] (half-open [start, end), wins over quote);
  note: React.ReactNode; label?: string; tone?: "primary" | "chart-1" ... "chart-5" }.
  tone is a TOKEN NAME, never a colour, so re-theming the app re-themes every
  annotation and dark mode is free.
- Also export the resolver as a pure function:
  resolveAnnotations(text, annotations) -> { anchored: AnchoredAnnotation[];
  rejected: RejectedAnnotation[] }, where AnchoredAnnotation = { annotation,
  index, start, end, quote } and RejectedAnnotation = { annotation, reason }
  with reason in "duplicate-id" | "empty" | "not-found" | "out-of-range" |
  "overlap". Export the reader-facing wording for each reason as one record, so
  the UI, your logs and your docs cannot drift apart.
- The note is announced as the marker's description, so it must be
  non-interactive content. A note that needs a link or a form is a Popover, not
  an annotation.

Behavior — anchoring (the maths that decides what a reader sees)
- Resolve each annotation to a half-open [start, end) span, walking the caller's
  array in order:
    * an id already used -> "duplicate-id" (the first one keeps it);
    * range present -> accept only integers with 0 <= start < end <= text.length.
      REFUSE, never clamp: a range left over from an older draft would otherwise
      silently mark the wrong words, and wrong words look exactly like right ones;
    * quote present -> the occurrence-th match of an EXACT, case-sensitive
      indexOf scan, counting overlapping matches (from = at + 1). Empty or
      whitespace-only quote -> "empty"; no such match -> "not-found";
    * neither -> "empty".
- Sort the survivors by start ascending, ties broken by the longer span, then by
  the caller's array position. That third key is what makes the result identical
  on the server and after hydration.
- Sweep left to right holding lastEnd: a candidate starting before lastEnd is
  rejected as "overlap". Spans cannot nest, because the segmentation below would
  have to render the same characters twice.
- NUMBER BY DOCUMENT ORDER, not by array order: index = position among the
  accepted spans, left to right. This is the one choice that makes three things
  agree without bookkeeping — markers read 1, 2, 3 across the sentence, the list
  under the passage matches them, and Tab walks them in the order the passage is
  read. index + 1 over the input array would number a passage by the order
  someone happened to type the notes in.
- Segment in one pass over the accepted spans: plain slice, marked span, plain
  slice, … Rejected annotations keep their note and are listed under a "Not
  anchored" heading with the reason. A lost editorial note is worse than a
  visible refusal.

Behavior — reveal
- ONE timer ref for both directions, cleared before every scheduling path and on
  unmount. A delay of 0 means "commit now", never "commit one macrotask later".
- Pointer: entering the phrase-plus-marker wrapper schedules an open after
  openDelay; leaving schedules a close after closeDelay; entering the panel
  clears the pending close, so the pointer can travel across the gap and read.
  Ignore pointerenter/pointerleave whose pointerType is "touch" — hover is not a
  touch gesture.
- Focus opens immediately and blur closes, unless the note was pinned. Keep a
  focusedId ref written by the marker's own focus/blur handlers and READ WHEN THE
  CLOSE COMMITS: a marker that still holds focus must keep its note open even
  though the pointer walked off it. Reading React state there would read the
  value captured when the timer was scheduled.
- Focus outranks the pointer when a close commits: if the note about to close
  belongs to some other marker but one marker still holds focus, reopen that
  marker's note rather than closing. A mouse brushing past an annotation would
  otherwise leave a keyboard reader focused on a marker with nothing on screen.
- Click or tap pins: pinned survives the pointer leaving, and clicking the same
  marker again closes. A pointerdown outside the root closes a pinned note (that
  listener exists only while something is pinned).
- Escape closes from anywhere: a document keydown listener in the CAPTURE phase,
  active only while a note is open, with stopPropagation — a hover-opened note is
  routinely on screen while focus is elsewhere entirely, and this panel is the
  innermost layer, so a surrounding dialog must not close on the same press.
- Keyboard map (markers are ordinary tab stops in reading order; no roving
  tabindex, because stealing Tab from a paragraph is worse than the extra stops):
    Tab / Shift+Tab  move between markers and out of the passage
    ArrowRight/Down  next marker in document order, wrapping
    ArrowLeft/Up     previous marker, wrapping
    Home / End       first / last marker
    Enter / Space    pin or unpin (preventDefault on Space, or the page scrolls)
    Escape           close
  Arrow moves focus straight out of a ref map inside the handler; nothing is
  deferred to an effect, so no key press is ever dropped.
- Each listed note carries a small button that focuses its marker, giving the
  list a way back into the passage.
- Nothing that can hold focus ever unmounts: the panel is deliberately
  non-focusable, so closing it can never drop focus onto <body>.

Behavior — placement (one measurement pass, no portal)
- The root is position: relative and the panel is absolutely positioned inside
  it. Compute the clip box first: the viewport intersected with the rect of every
  ancestor whose computed overflowX/overflowY is not "visible". A docs stage, a
  dashboard card or a scroll container would otherwise cut the panel in half.
- Horizontally the panel belongs to the passage: the band it centres in is the
  root's rect intersected with the clip box; width = max(MIN_WIDTH,
  min(noteWidth, band width)); centre on the marker, then clamp into the band.
- Vertically, take the panel's NATURAL height as scrollHeight + (offsetHeight -
  clientHeight). offsetHeight is already capped by the max-height this same
  function applied last pass, so a flip decided from it would be decided from a
  box we shrank ourselves. Stay below unless below cannot hold it and above is
  genuinely roomier; publish maxHeight from the room that exists and let the
  panel scroll.
- Convert to root-relative coordinates with rect.left + root.clientLeft (and the
  same for top): absolutely positioned children are placed against the padding
  box, which is the border-box origin plus the root's own border widths.
- The first measurement is the ResizeObserver's initial delivery on observe(), so
  no state is ever set from an effect body. Until it lands the panel is
  opacity-0 pointer-events-none — never visibility:hidden, which would also make
  it unmeasurable in the way that matters and unfocusable if it ever gains
  content. Guard the setState with a placement-equality check, or the observer
  re-fires on the max-height it just applied and loops.
- Re-measure from a ResizeObserver on both panel and root plus capture-phase
  scroll and resize. All of it — observer, listeners, timer — is released when
  the note closes, when the active id changes and on unmount. When the active id
  changes, drop the stored placement DURING RENDER (React's "adjust state on a
  previous value" pattern) so the panel never paints one frame at the previous
  marker's coordinates.

Rendering & styling
- The marker is a <span role="button" tabIndex={0}> with display: inline, NOT a
  <button>: browsers force inline-block on buttons, which is an atomic inline —
  it creates a soft-wrap opportunity (the number gets orphaned onto the next
  line) and it contributes to the line box (annotating a paragraph would change
  its leading). Write it with NO whitespace after the phrase, because an inline
  boundary is not a wrap opportunity. An inline box's padding paints without
  joining the line box, so with leading-none the marker can be a padded pill and
  the paragraph's line height still does not move.
- The phrase itself is a real <mark> with box-decoration-clone (the band repaints
  on every wrapped line), a dotted underline in the tone, and an EXPLICIT
  text-foreground: Tailwind preflight does not reset <mark>, and the UA's
  marktext colour is black on black in dark mode.
- Tones: bands are color-mix(in oklab, var(--token) N%, transparent) — 14% idle
  and 26% active behind the phrase, 32%/46% behind the marker, 60% for the
  underline. Nothing goes above ~46%: a translucent band sits between the page
  and the text in both colour schemes, which is what keeps contrast after a
  theme switch. The panel's inline-start border is the token at full strength,
  since no text sits on it.
- select-none on the marker, so selecting and copying the passage does not carry
  the numbers with it.
- Panel: bg-popover / text-popover-foreground / border / shadow-md, rounded-lg,
  overflow-auto overscroll-contain, and it fades up over a few pixels. Transition
  the `translate` PROPERTY, not `transform`: Tailwind v4's translate-y-*
  utilities write `translate:`, so transition-[opacity,transform] would fade the
  panel in and snap it into place. motion-reduce:transition-none — under reduced
  motion it simply appears, and every feature still works with motion off.
- Semantic tokens only throughout: bg-popover, bg-muted, text-foreground,
  text-muted-foreground, text-popover-foreground, border, ring-ring,
  var(--primary), var(--chart-1..5). No hex, rgb or oklch anywhere.
- ARIA contract:
    * marker: aria-label "Annotation n" plus aria-describedby pointing at the
      note's body IN THE LIST below, so assistive tech gets the whole note
      without any interaction at all;
    * the panel is aria-hidden — it is a sighted-only copy of a note that is
      already in the accessibility tree, and announcing it twice is noise;
    * no aria-expanded and no aria-haspopup: nothing pops up in the
      accessibility tree, and advertising it would be a lie;
    * notes="hidden" makes the list sr-only and DROPS the jump buttons, because
      a visually hidden button is still a real tab stop;
    * the list is <ol>, so the numbering is structural too, and each item shows
      the quoted phrase, the optional label and the note.
- With no annotations (or none that anchored) the notes block is not rendered at
  all — no empty heading left behind.

Customization levers
- Marker shape: it is a padded pill on the baseline. Brackets ([n]), a
  superscript (align-super, drop the background) or a bare dot are className-only
  changes — keep display: inline and leading-none, or the line-height guarantee
  goes with them.
- Tone palette: one tone per kind of note (legal / editorial / support) reads
  better than one tone per note; chart-1..5 give you five that already coexist in
  the theme. Raise the band alphas only after checking contrast in dark mode.
- Timing: openDelay is the "did they mean it" threshold (120-300ms reads well;
  0 for a dense review UI); closeDelay only has to cover the pointer's trip to
  the panel. Set both to 0 and the component becomes an instant-reveal one.
- notes: "list" for documents that are read, "hidden" for chrome where the
  markers are enough on screen — the accessibility path is identical either way.
  notesLabel retitles the block ("Editor notes", "Footnotes").
- Panel anatomy: the header row (number chip + label) and the body are
  independent; add a per-note author, a timestamp passed in as a prop, or a
  status pill without touching the open/close machinery. Keep it non-focusable.
- Anchoring style: quotes survive light copy-editing, offsets survive
  duplicated phrases; a review pipeline usually stores offsets and falls back to
  quotes. occurrence is the cheap middle ground for a phrase that repeats.
- Refusals: the "Not anchored" group is where an editorial pipeline sees its own
  bugs. Feed resolveAnnotations() into your tests or your CI to fail a document
  whose notes stopped matching, instead of shipping a passage that quietly lost
  half of them.
- Layout: className lands on the root, so max-width, type size and leading are
  yours; noteWidth sizes the panel, and it narrows itself when the passage is
  narrower than that.

Concepts

  • Document-order numbering — the number is the marker's position in the passage, not the annotation's position in the array. That single choice makes the markers, the list underneath and the Tab order agree by construction, and it keeps server and client rendering identical because the sort has a total order (start, then longer span, then array position).
  • Refuse, don't clamp — a range outside the passage, an inverted range, a quote that no longer occurs and a span that overlaps an earlier one are all reported instead of being nudged into place. An annotation nudged onto the wrong words is indistinguishable from a correct one; a refusal is at least visible, and the note survives it in the "Not anchored" group.
  • List as the accessible source of truth — the note lives once in the DOM, under the passage, and the marker points at it with aria-describedby. The floating panel is a sighted-only copy and is aria-hidden, so assistive tech never has to hover anything, notes="hidden" costs nothing in the accessibility tree, and the panel can stay non-focusable — which is why closing it can never strand focus.
  • Line-box-safe marker — an inline non-replaced box's padding paints outside its contribution to the line box, so with leading-none a padded number cannot change a paragraph's leading; and because an inline boundary is not a soft-wrap opportunity, a marker written flush against its phrase can never be orphaned onto the next line. Both properties are lost the moment the marker becomes a <button>, which browsers force to inline-block.
  • Grace period, one timeropenDelay is the promise that sweeping a pointer across an annotated paragraph opens nothing; closeDelay is the window the pointer needs to reach the panel. A single timer, cleared on every scheduling path and on unmount, means a fast sweep leaves no queue of notes about to appear.
  • Measure against what clips you — the panel is flipped and clamped against the viewport intersected with every ancestor that clips its overflow, and its natural height is read from scrollHeight rather than the offsetHeight the previous pass already capped. Measuring against the window instead is how an overlay ends up drawn inside a card: present in the DOM, invisible on screen.

On This Page