Selection Annotate Toolbar
A toolbar that follows a text selection and highlights ranges without touching the DOM.
- Category
- Patterns
- Added
- 2026-09-23
- Deps
- none
- Updated
- 2026-09-23
This is a viberdy Pro component
The live preview, the props panel and the write-up are open to everyone. The full source, the AI prompt and CLI install are part of Pro.
The toolbar is placed from the first or last client rect of the range, never from its bounding box. A selection running across three lines has a bounding box as wide as the column, so anything centred on it lands nowhere near the text the reader actually chose.
Select any of the text below.
Props
"use client";
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
type HighlightRegistry = {
set: (name: string, value: object) => void;
delete: (name: string) => void;
};
const DEFAULT_HL = "rgba(214,255,0,0.26)";
/**
* A colour is about to be CONCATENATED INTO A STYLESHEET, which is a rule
* injection sink, not an HTML one — React escapes nothing inside a style
* element, and correctly so, because that text is CSS. A value like
* "red} *{display:none!important} a{color:red" closes the declaration early
* and injects page-wide rules; the same primitive is what CSS exfiltration
* attacks are built on. Anything that is not plainly a colour is refused.
// ---------------------------------------------------------------
// Full source available with a viberdy Pro subscription.
// https://viberdy.dev/pricing
// ---------------------------------------------------------------
}About this pattern
Reading apps have trained everyone to expect a small bar over a text selection, and the pattern has two failure modes that are both invisible until someone uses it. The first is placement: a naive implementation centres on the range bounding box, which for a selection crossing three lines is as wide as the entire column, so the toolbar appears in the middle of the paragraph rather than beside the chosen words. Ranges expose one client rect per line, and anchoring to the first or last of those puts the bar where the selection visibly begins or ends. The second is focus. A mousedown anywhere collapses the current selection, so a toolbar that does not call preventDefault on pointerdown loses the range before its own click handler runs, and every button silently does nothing. Highlighting is the third trap and the one with the worst consequences: wrapping selected nodes in mark elements mutates a subtree React owns and expects to reconcile, so the next render either erases the markup or throws. The CSS Custom Highlight API paints ranges at paint time with no DOM mutation at all, which makes it the only safe way to do this inside a React tree.
Curator’s note
If one of these toolbars ever appeared and then did nothing when clicked, it was missing preventDefault on pointerdown. The selection was gone before the handler ran. Second warning, less obvious: highlightColor is concatenated into a stylesheet, so it is validated before use — if you widen that check, remember a style tag escapes nothing and a colour field wired to user input becomes a page-wide CSS injection.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Masked Scroll Highlight
text effectsCopy that resolves word by word as you scroll, mapped by real reading position rather than word index.
Sticky Scroll Stack
patternsCards that pin and stack on top of each other as you scroll past.
Draggable Sortable List
patternsA drag-handle-driven list that reorders with a smooth layout swap.