Anchor the annotation to a DOM Range, then position its label using the range’s rendered fragment rectangles. This guide shows a small Mac desktop browser demo that keeps a note attached when a phrase crosses inline elements and wraps onto several lines.
The task is specific: attach one visible annotation to a phrase in an ordinary webpage layout without rebuilding its text. You will identify the phrase, choose a fragment for the label, and remeasure after layout changes. MDN documents Range.getClientRects() as the browser API for retrieving the rectangles occupied by a range. Range: getClientRects() method.
Why does a label drift away from wrapped text?
A phrase has two relevant representations: its position in the document and its current geometry on screen. WHATWG defines a range through start and end boundary points, each consisting of a node and an offset. Those endpoints can lie in different text nodes, allowing one range to cross an existing emphasis element. DOM Standard.
A single bounding rectangle loses detail. Imagine a phrase beginning halfway through one line and ending near the start of the next. Its enclosing rectangle includes empty space beside both lines. A label centered on that rectangle can therefore appear disconnected from the words. Floating UI explicitly identifies this problem for multiline links and range selections. inline.
The individual rectangles also need interpretation. W3C specifies that a range can return both selected element border rectangles and text rectangles, in content order. Nested elements can therefore produce overlapping rectangles; the rectangle count is not a reliable line count. The text bounds use font metrics rather than exact painted glyph outlines. CSSOM View Module.
Which fragment should own the annotation?
Choose a placement rule before writing positioning code. The following decision aid separates the text being annotated from the point where its label appears.
| Intended behavior | Reference geometry | Placement rule |
|---|---|---|
| A note following a phrase | The complete range | Attach below its final usable fragment in a simple horizontal, left-to-right layout |
| A label near a pointer interaction | The range’s fragment rectangles | Choose the fragment at the interaction’s client coordinates |
| A centered overview label | The enclosing rectangle | Accept that the label may sit over empty space |
| A background marking the whole phrase | The complete range | Use a text highlight independently of label placement |
The demo uses the first rule. It deliberately fixes the writing mode and direction so that the final rectangle is a useful endpoint reference. Treat that as a condition of the example. W3C specifies content order for these rectangles; it does not promise a universal order from the upper-left corner to the lower-right corner. CSSOM View Module.
For pointer-based placement with Floating UI, its inline() middleware accepts viewport-relative x and y coordinates. The documentation limits that coordinate-based choice to disjoined rectangles, so it should not be described as an unrestricted character picker. inline.
Build a complete wrapped-text demo on a Mac
Use a current desktop Firefox version and open a blank tab at about:blank. Press Command + Option + K to open the page’s Web Console. Mozilla documents this shortcut and confirms that the console executes JavaScript in the page context. Web Console.
Run the complete program below in that console. It creates all its sample content, styles and controls, so no project, imports or external library are required. The synthetic phrase crosses an existing emphasis element. The annotation is a display-only label; it has no editing controls.
(() => {
window.fragmentDemo?.destroy();
const host = document.createElement('section');
host.style.cssText = 'margin:48px 24px;padding-bottom:900px';
const p = document.createElement('p');
p.dir = 'ltr';
p.style.cssText = 'font:20px/1.6 Georgia,serif;width:24rem;max-width:80vw;writing-mode:horizontal-tb';
const a = document.createTextNode('Sample A: amber tiles follow the curved path ');
const em = document.createElement('em');
em.textContent = 'past the small blue circle and ';
const c = document.createTextNode('toward the final silver square. End.');
p.append(a, em, c);
const button = document.createElement('button');
button.textContent = 'Change text width';
const status = document.createElement('p');
host.append(button, p, status);
document.body.append(host);
const range = document.createRange();
range.setStart(a, 'Sample A: '.length);
range.setEnd(c, c.data.indexOf(' End.'));
const note = document.createElement('div');
note.textContent = 'Note A: review this phrase';
note.style.cssText = 'position:fixed;z-index:1000;background:#fff2ad;color:#222;padding:6px 10px;font:14px/1.4 system-ui;pointer-events:none';
document.body.append(note);
const style = document.createElement('style');
style.textContent = '::highlight(fragment-demo) {background-color:#dbeafe;}';
document.head.append(style);
const canHighlight = typeof Highlight !== 'undefined' && !!window.CSS?.highlights;
if (canHighlight) CSS.highlights.set('fragment-demo', new Highlight(range));
status.textContent = canHighlight ? 'The blue phrase owns Note A.' : 'Text highlighting unavailable; Note A still uses the range.';
function update() {
if (!host.isConnected) return;
const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0);
const r = rects.at(-1);
note.hidden = !r || range.collapsed;
if (note.hidden) return;
const maxLeft = Math.max(8, innerWidth - note.offsetWidth - 8);
note.style.left = Math.max(8, Math.min(r.left, maxLeft)) + 'px';
note.style.top = (r.bottom + 8) + 'px';
}
let narrow = false;
button.onclick = () => {
narrow = !narrow;
p.style.width = narrow ? '16rem' : '24rem';
update();
};
const observer = new ResizeObserver(update);
observer.observe(p);
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
document.fonts.ready.then(update);
update();
window.fragmentDemo = {destroy() {
observer.disconnect();
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
if (canHighlight) CSS.highlights.delete('fragment-demo');
host.remove(); note.remove(); style.remove();
}};
})();
The endpoints exclude the prefix Sample A: and the suffix End.. Click Change text width to alternate between two paragraph widths. The same range remains the annotation target while the label is repositioned below its final usable fragment. Scroll and resize the window to inspect the behavior. Remove the demo with window.fragmentDemo.destroy().
The program passed a JavaScript syntax check, and its sample offsets and fragment-position arithmetic passed deterministic checks. Browser rendering was not executed here; the steps above provide the visual verification.
How does the demo preserve text flow and shaping?
The range measures the existing text. The program leaves the phrase’s text nodes and emphasis element in place after constructing the sample. It adds the label separately and optionally paints the phrase through the CSS Custom Highlight API, which MDN documents as working without changing the underlying DOM structure. CSS custom highlight API.
That distinction matters when adapting the approach. Adding inline padding, borders or margins between character units can introduce shaping boundaries. W3C also identifies noninitial vertical-align and bidirectional isolation boundaries as conditions that break shaping. Avoid inserting individually positioned character wrappers merely to obtain coordinates; measure the browser’s existing result. CSS Text Module Level 3, Shaping Across Element Boundaries.
For actual application text, derive range endpoints from the intended text boundaries. DOM text offsets use code units; they are not counts of visible glyphs. Keep endpoints aligned with complete intended characters rather than assuming every displayed character occupies one offset. DOM Standard.
Highlighting is optional. MDN lists Highlight as Baseline 2025 and notes that older browsers may lack it. The feature check in the demo preserves annotation placement when highlighting is unavailable and displays an explanatory status message. Highlight.
What must update when the page reflows?
The demo remeasures on paragraph resizing, window resizing and scrolling. ResizeObserver detects changes to element dimensions, including changes unrelated to resizing the browser window. Its callback here moves only the separate label, avoiding a feedback loop that repeatedly changes the measured paragraph’s size. ResizeObserver.
Font loading deserves its own update. The program measures immediately, then measures again when document.fonts.ready resolves. MDN states that this promise resolves after the document’s font loading and associated layout operations complete. Later application-driven font or content changes should explicitly request another measurement. FontFaceSet: ready property.
Keep coordinates in one system. The demo uses a fixed-position label on a blank page and applies client coordinates directly. Adding document scroll offsets would change those into document coordinates. MDN explains that client rectangles are viewport-relative and that adding scroll offsets is the conversion used when document-relative coordinates are wanted. Element: getBoundingClientRect() method.
This small example clamps horizontal placement but does not implement vertical collision avoidance, offscreen hiding or arbitrary transformed containers. It also does not restore an annotation after a reload or a complete replacement of the text. For an application, retain a way to identify the intended passage, rebuild its range after rendering, and then measure again.
When should you use Floating UI?
Use Floating UI when the label needs clipping-aware placement and a managed update lifecycle. Its documented virtual-reference interface supports a range through two methods: getBoundingClientRect() returning the range’s enclosing rectangle and getClientRects() returning its fragment rectangles. Supply contextElement with a real element containing the text so clipping and update detection have an element context. Virtual Elements.
Pass that virtual reference to computePosition() and include inline() near the beginning of the middleware array, before flip(). Match the floating element’s CSS positioning to the selected strategy. The documentation describes flip() for changing sides when space is insufficient and shift() for moving a label within its clipping limits. inline, computePosition.
Start autoUpdate() only while the annotation is mounted, and call its returned cleanup function when removing the label. Its documented update mechanisms cover ancestor scrolling, resizing and layout shifts. Still trigger an update after an application change that alters the chosen text’s fragments without changing the observed container’s dimensions. autoUpdate.
Diagnose an annotation that still looks wrong
Work through these checks in order so that a geometry problem does not disguise a text-targeting problem.
- Confirm the phrase. Inspect
range.toString()and check both endpoints before adjusting coordinates. - Inspect the fragments. Log
Array.from(range.getClientRects()). Multiple overlapping entries can reflect nested inline content; do not interpret the count as a line count. - Check the placement rule. An enclosing rectangle, final fragment and pointer-selected fragment serve different annotation behaviors.
- Check the coordinate system. For the blank-page demo, keep the fixed label in client coordinates.
- Repeat after layout changes. Change width, scroll, resize and let fonts finish loading. Rebuild the range if the application replaces its text nodes.
- Check the layout assumptions. For vertical writing, mixed directions, columns or transformed containers, define and test an appropriate fragment-selection rule before reusing the demo’s final-fragment shortcut.
A reliable annotation starts with an accurate text range and an explicit placement rule. Keep the browser responsible for wrapping and shaping, then remeasure its rendered fragments whenever the relevant layout changes. That gives the note a stable relationship to the phrase while allowing the words to flow naturally.
Sources
- DOM Standard — WHATWG
- CSSOM View Module — W3C
- CSS Text Module Level 3 — W3C
- Range: getClientRects() method — MDN
- Element: getBoundingClientRect() method — MDN
- CSS custom highlight API — MDN
- Highlight — MDN
- ResizeObserver — MDN
- FontFaceSet: ready property — MDN
- Web Console — Mozilla
- inline — Floating UI
- Virtual Elements — Floating UI
- computePosition — Floating UI
- autoUpdate — Floating UI