Text
Highlight Marker
Highlighter, underline or hand-drawn circle that wipes across a phrase when it scrolls into view.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/highlight-marker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HighlightMarker" component (one
IntersectionObserver, CSS transitions, no animation library).
Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>
that wraps children (the phrase being annotated).
- Props: variant = "marker" | "underline" | "circle" (default "marker"),
color (TOKEN NAME ONLY: "primary" | "chart-1".."chart-5", default
"primary"), trigger = "view" | "mount" (default "view"), duration (ms,
default 700), delay (ms, default 0).
- The color prop maps to var(--primary) / var(--chart-N) internally — the
consumer cannot pass a hex, so annotations always follow the theme.
Behavior
- marker / underline are one background-image on the wrapper itself:
linear-gradient(to right, band, band), background-repeat: no-repeat,
and background-size animating from 0% to 100% width. Nothing but
background-size transitions, so the wipe never reflows the paragraph.
marker -> band height ~62%, background-position "0 86%": the stripe
sits low across the text like a real highlighter stroke.
underline -> band height ~0.18em, background-position "0 100%": a rule
under the words.
Use box-decoration-break: clone so a phrase that wraps gets the band on
every line instead of one stretched box.
- circle renders an absolutely-positioned SVG over the phrase (the wrapper
becomes relative inline-block, the svg is inset slightly outside it). Draw
one hand-drawn open ellipse path that overshoots its own start point, set
pathLength={1} so stroke-dasharray/offset are normalized to 1, and
transition stroke-dashoffset 1 -> 0 to draw it. preserveAspectRatio="none"
stretches the 100x100 viewBox to the phrase, and vector-effect:
non-scaling-stroke keeps the pen width even after that stretch.
- trigger="view": IntersectionObserver (threshold ~0.5) flips a "shown"
state the first time the phrase intersects, then unobserves the node and
disconnects on unmount — it draws exactly once.
trigger="mount": flip the state inside a requestAnimationFrame so the 0%
frame is committed first and the transition actually has a start point
(cancel the frame on unmount).
- prefers-reduced-motion (useSyncExternalStore over matchMedia, server
snapshot false): render the completed state immediately (band at 100%,
ellipse fully drawn) and pass no transition at all — the annotation is
information, only the drawing is decoration.
Rendering & styling
- Semantic tokens only. The band is color-mix(in oklab, var(--token) 26%,
transparent) for marker and 60% for underline: because the token flips
with the color scheme, a translucent band lands light behind dark text in
light mode and dark behind light text in dark mode — the phrase stays
readable either way. Never paint an opaque band behind text.
- The circle stroke uses the token at full strength (it does not sit behind
glyphs) and the svg is aria-hidden + pointer-events-none.
- Accessibility: the text itself is untouched DOM — selectable, searchable
and announced normally. Only the decorative svg layer is hidden. Merge
className via cn() and merge the consumer's style after the computed
background so per-instance overrides still work.
Customization levers
- Variant is the tone: marker for marketing emphasis, underline for a
quieter editorial accent, circle for a "handwritten note" feel.
- Color: chart-1..5 give you an annotation palette; use one color per idea
across a page instead of many colors in one paragraph.
- Band geometry: the 62% height / "0 86%" position pair is the whole
highlighter look — raise the position for a strike-through feel, lower the
height for a thin marker.
- Opacity: the 26% color-mix is the readability guard; raise it only after
checking contrast in dark mode.
- Choreography: stagger delay across several phrases (0ms, 200ms, 400ms) to
make a paragraph annotate itself in reading order; duration 500-900ms
reads as a hand moving, faster reads as a UI transition.
- Pen shape: the ellipse is a single path string — replace it with a
bracket, a scribble or a box and the draw-on animation still works,
because it only depends on pathLength.Concepts
- Annotate, don't restyle — the phrase keeps its own color and weight; the component only adds a layer behind or under it, which is why it can be dropped into body copy without disturbing the type hierarchy.
- Wipe by background-size — growing the band from
0%to100%animates a paint-only property, so the surrounding paragraph never reflows and the effect is cheap enough for several phrases on one screen. - Translucent band as the readability guard — mixing the token with transparency (rather than using it opaque) means the highlight sits between the background and the text in both color schemes, so contrast survives a dark-mode switch for free.
- Normalized path length —
pathLength={1}makes the ellipse's dash math independent of its real geometry, so any replacement path (scribble, bracket, box) draws itself with the same one-line transition. - Non-scaling stroke — the SVG is stretched to the phrase with
preserveAspectRatio="none", which would squash the pen width;vector-effect: non-scaling-strokekeeps the stroke even across short and long phrases. - Draw once, then stop watching — the observer unobserves on its first hit, so scrolling back and forth never redraws and no observer outlives the component.