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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

// consumers pick a token name, nothing else — swapping the theme swaps the colours
const COLORS = {
  primary: "var(--primary)",
  "chart-1": "var(--chart-1)",
  "chart-2": "var(--chart-2)",
  "chart-3": "var(--chart-3)",
  "chart-4": "var(--chart-4)",
  "chart-5": "var(--chart-5)",
} as const

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/highlight-marker.json

Prompt

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% to 100% 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 lengthpathLength={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-stroke keeps 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.

On This Page