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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/annotated-text.jsonPrompt
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 isaria-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-nonea 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 toinline-block. - Grace period, one timer —
openDelayis the promise that sweeping a pointer across an annotated paragraph opens nothing;closeDelayis 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
scrollHeightrather than theoffsetHeightthe 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.
Vertical Text
Vertical CJK writing mode — right-to-left columns whose length is a character count, short Latin and digit runs stood upright as tate-chū-yoko, kinsoku column breaks, and an automatic fallback to horizontal when the script would not gain from it.
Grid Dots
A pure-CSS grid, dot or cross pattern container with a radial fade — a zero-JS decorative backdrop for hero and section surfaces.