"use client";
import * as React from "react";
export type MarkerType = "highlight" | "underline" | "circle" | "box" | "strike" | "bracket";
export type MarkerTrigger = "view" | "hover" | "mount";
export interface MarkerHighlightProps extends React.HTMLAttributes<HTMLSpanElement> {
/** Annotation style. */
type?: MarkerType;
/** Stroke / marker colour (any CSS colour). */
color?: string;
/** Stroke width in px (the highlight marker is sized from the line height instead). */
strokeWidth?: number;
/** Draw time in ms (shared across lines for multiline annotations). */
duration?: number;
/** Delay before drawing, in ms. */
delay?: number;
/** What draws the annotation. Hover also responds to keyboard focus within the element. */
trigger?: MarkerTrigger;
/** Space between the text and the annotation, in px. */
padding?: number;
/** Seed for the hand-drawn wobble. The same seed always draws the same shape. */
seed?: number;
/** Annotate each line separately (highlight, underline, strike). Otherwise one box is used. */
multiline?: boolean;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* ------------------------------- geometry --------------------------------- */
interface Box {
x: number;
y: number;
w: number;
h: number;
}
interface Stroke {
d: string;
width: number;
/** Share of the total length — used to split the draw time across strokes. */
length: number;
}
/** Small deterministic PRNG, so the same seed always produces the same wobble. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const f = (n: number) => n.toFixed(2);
/** Catmull-Rom → cubic Bézier through the points, for organic curves. */
function smoothPath(points: [number, number][], closed = false) {
if (points.length < 2) return "";
const p = points;
let d = `M${f(p[0][0])},${f(p[0][1])}`;
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] ?? (closed ? p[p.length - 1] : p[i]);
const p1 = p[i];
const p2 = p[i + 1];
const p3 = p[i + 2] ?? p2;
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += `C${f(c1[0])},${f(c1[1])} ${f(c2[0])},${f(c2[1])} ${f(p2[0])},${f(p2[1])}`;
}
return d;
}
/** A slightly wavering horizontal stroke from x0 to x1 around y. */
function wobblyLine(rand: () => number, x0: number, x1: number, y: number, amp: number) {
const segments = Math.max(2, Math.round((x1 - x0) / 60));
const tilt = (rand() - 0.5) * amp * 1.6;
const points: [number, number][] = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
points.push([x0 + (x1 - x0) * t + (i === 0 || i === segments ? 0 : (rand() - 0.5) * 6), y + tilt * (t - 0.5) + (rand() - 0.5) * amp]);
}
return smoothPath(points);
}
function union(boxes: Box[]): Box {
const x = Math.min(...boxes.map((b) => b.x));
const y = Math.min(...boxes.map((b) => b.y));
const r = Math.max(...boxes.map((b) => b.x + b.w));
const b = Math.max(...boxes.map((b) => b.y + b.h));
return { x, y, w: r - x, h: b - y };
}
function buildStrokes(type: MarkerType, lines: Box[], { seed, padding, strokeWidth, multiline }: { seed: number; padding: number; strokeWidth: number; multiline: boolean }): Stroke[] {
if (!lines.length) return [];
const perLine = multiline && (type === "highlight" || type === "underline" || type === "strike");
const boxes = perLine ? lines : [union(lines)];
return boxes.flatMap((box, i): Stroke[] => {
const rand = mulberry32(seed * 9973 + i * 131 + 7);
const x0 = box.x - padding;
const x1 = box.x + box.w + padding;
const y0 = box.y - padding;
const y1 = box.y + box.h + padding;
switch (type) {
case "highlight": {
// A felt-tip swipe: thick, slightly tilted, sized from the line height. Round caps soften the
// ends, so the stroke is inset by roughly half its width to keep the overall extent.
const width = box.h * 0.66;
const inset = width * 0.38;
return [{ d: wobblyLine(rand, x0 + inset, x1 - inset, box.y + box.h * 0.56, box.h * 0.09), width, length: x1 - x0 }];
}
case "underline":
return [{ d: wobblyLine(rand, x0, x1, box.y + box.h * 0.94 + padding * 0.35, Math.min(4, box.h * 0.08)), width: strokeWidth, length: x1 - x0 }];
case "strike":
return [{ d: wobblyLine(rand, x0, x1, box.y + box.h * 0.56, Math.min(3, box.h * 0.06)), width: strokeWidth, length: x1 - x0 }];
case "box": {
// Four separate strokes with overshoot at the corners, like a quick pen box.
const o = () => (rand() - 0.3) * 6;
const j = () => (rand() - 0.5) * 3;
const top = smoothPath([[x0 + o(), y0 + j()], [(x0 + x1) / 2, y0 + j()], [x1 + o(), y0 + j()]]);
const right = smoothPath([[x1 + j(), y0 + o()], [x1 + j(), (y0 + y1) / 2], [x1 + j(), y1 + o()]]);
const bottom = smoothPath([[x1 - o(), y1 + j()], [(x0 + x1) / 2, y1 + j()], [x0 - o(), y1 + j()]]);
const left = smoothPath([[x0 + j(), y1 - o()], [x0 + j(), (y0 + y1) / 2], [x0 + j(), y0 - o()]]);
const w = x1 - x0;
const h = y1 - y0;
return [
{ d: top, width: strokeWidth, length: w },
{ d: right, width: strokeWidth, length: h },
{ d: bottom, width: strokeWidth, length: w },
{ d: left, width: strokeWidth, length: h },
];
}
case "circle": {
// An ellipse that overshoots its start, with a radius that drifts as it goes round.
const cx = (x0 + x1) / 2;
const cy = (y0 + y1) / 2;
// Slightly larger than the padded box so the ellipse clears the text's corners.
const rx = ((x1 - x0) / 2) * 1.06 + 4;
const ry = ((y1 - y0) / 2) * 1.32 + 3;
const start = Math.PI * (0.9 + rand() * 0.25);
const sweep = Math.PI * 2 + 0.35 + rand() * 0.25;
const steps = 16;
const points: [number, number][] = [];
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const a = start + sweep * t;
const drift = 1 + (t - 0.5) * 0.08 + (rand() - 0.5) * 0.035;
points.push([cx + Math.cos(a) * rx * drift, cy + Math.sin(a) * ry * drift]);
}
const length = Math.PI * (rx + ry) * (sweep / (Math.PI * 2));
return [{ d: smoothPath(points), width: strokeWidth, length }];
}
case "bracket": {
const arm = Math.min(12, (x1 - x0) * 0.2);
const j = () => (rand() - 0.5) * 2.5;
const left = `M${f(x0 + arm + j())},${f(y0 + j())} L${f(x0 + j())},${f(y0 + j())} L${f(x0 + j())},${f(y1 + j())} L${f(x0 + arm + j())},${f(y1 + j())}`;
const right = `M${f(x1 - arm + j())},${f(y0 + j())} L${f(x1 + j())},${f(y0 + j())} L${f(x1 + j())},${f(y1 + j())} L${f(x1 - arm + j())},${f(y1 + j())}`;
const length = arm * 2 + (y1 - y0);
return [
{ d: left, width: strokeWidth, length },
{ d: right, width: strokeWidth, length },
];
}
}
});
}
/** Reads the element's line boxes relative to the overlay's own origin. */
function measure(root: HTMLElement, overlay: SVGSVGElement): Box[] {
const origin = overlay.getBoundingClientRect();
return Array.from(root.getClientRects())
.filter((r) => r.width > 1 && r.height > 1)
.map((r) => ({ x: r.left - origin.left, y: r.top - origin.top, w: r.width, h: r.height }));
}
function sameBoxes(a: Box[], b: Box[]) {
return a.length === b.length && a.every((box, i) => Math.abs(box.x - b[i].x) < 0.5 && Math.abs(box.y - b[i].y) < 0.5 && Math.abs(box.w - b[i].w) < 0.5 && Math.abs(box.h - b[i].h) < 0.5);
}
/**
* Wraps inline text and draws a hand-made annotation (marker swipe, underline, circle, box,
* strike-through or brackets) when it scrolls into view, on hover or on mount. Shapes are
* generated from a seed, so they look hand-drawn but are identical on every render.
*/
export function MarkerHighlight({
type = "highlight",
color = "#FF7448",
strokeWidth = 2,
duration = 800,
delay = 0,
trigger = "view",
padding = 4,
seed = 7,
multiline = true,
className = "",
style,
children,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: MarkerHighlightProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLSpanElement>(null);
const overlayRef = React.useRef<SVGSVGElement>(null);
const [lines, setLines] = React.useState<Box[]>([]);
const [shown, setShown] = React.useState(false);
// Measure line boxes after layout, on resize and once web fonts settle (setState only in callbacks).
React.useEffect(() => {
const root = rootRef.current;
const overlay = overlayRef.current;
if (!root || !overlay) return;
let frame = 0;
const update = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const next = measure(root, overlay);
setLines((prev) => (sameBoxes(prev, next) ? prev : next));
});
};
update();
// Inline elements never report resizes, so watch the block that contains the text.
const observer = new ResizeObserver(update);
observer.observe(root.parentElement ?? root);
window.addEventListener("resize", update);
document.fonts?.ready.then(update).catch(() => {});
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener("resize", update);
};
}, []);
const measured = lines.length > 0;
// Mount and in-view triggers. A frame passes first so the undrawn state is painted.
React.useEffect(() => {
const root = rootRef.current;
if (!root || !measured || trigger === "hover") return;
let frame = 0;
const reveal = () => {
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => setShown(true));
});
};
if (trigger === "mount") {
reveal();
return () => cancelAnimationFrame(frame);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
reveal();
}
},
{ threshold: 0.6 },
);
observer.observe(root);
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [trigger, measured]);
const strokes = buildStrokes(type, lines, { seed, padding, strokeWidth, multiline });
const total = strokes.reduce((sum, s) => sum + s.length, 0) || 1;
const drawn = reduceMotion || shown;
const animate = !reduceMotion;
// Strokes draw one after another; each gets a share of the duration by its length.
const timing: { start: number; share: number }[] = [];
for (let i = 0, acc = 0; i < strokes.length; i++) {
const share = (strokes[i].length / total) * duration;
timing.push({ start: delay + acc, share });
acc += share;
}
const engage = (on: boolean) => {
if (trigger === "hover") setShown(on);
};
return (
<span
ref={rootRef}
className={`relative isolate ${className}`}
style={style}
onPointerEnter={(event) => {
onPointerEnter?.(event);
engage(true);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
engage(false);
}}
onFocus={(event) => {
onFocus?.(event);
engage(true);
}}
onBlur={(event) => {
onBlur?.(event);
engage(false);
}}
{...props}
>
{children}
<svg
ref={overlayRef}
aria-hidden="true"
focusable="false"
width={1}
height={1}
className="pointer-events-none absolute top-0 left-0 -z-10 overflow-visible"
>
{strokes.map((stroke, i) => {
const { start, share } = timing[i];
return (
<path
key={i}
d={stroke.d}
pathLength={1}
fill="none"
stroke={color}
strokeWidth={stroke.width}
strokeLinecap="round"
strokeLinejoin="round"
className={type === "highlight" ? "opacity-[0.36] dark:opacity-50" : undefined}
style={{
// The gap is slightly longer than the path, so no round cap peeks out while undrawn.
strokeDasharray: "1 1.05",
strokeDashoffset: drawn ? 0 : 1.02,
transition: animate
? `stroke-dashoffset ${Math.round(share)}ms cubic-bezier(0.65, 0, 0.35, 1) ${drawn ? Math.round(start) : 0}ms`
: "none",
}}
/>
);
})}
</svg>
</span>
);
}