Skip to content

Selection Annotate Toolbar

A toolbar that follows a text selection and highlights ranges without touching the DOM.

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

Preview

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

10
Highlight action
No runtime dependencies
"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
  // ---------------------------------------------------------------
}
Notes

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.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
NewPro

Copy that resolves word by word as you scroll, mapped by real reading position rather than word index.

scrolltypereveal

Cards that pin and stack on top of each other as you scroll past.

scrollstickystack

A drag-handle-driven list that reorders with a smooth layout swap.

dragsortablelist