"use client"; import { Fragment, createElement, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import "./highlight-sweep.css"; /** * Highlight Sweep * A second cursor — not yours — that shows people what matters. It glides * in, presses at the start of a line and drags right, and a selection box * grows behind the text as it goes: the words turn the accent colour with a * crisp edge that travels with the cursor. It lets go and moves on to the * next phrase. By default it plays once: every highlight stays, the arrow * comes to rest past the last one, and nothing moves again. Pass * repeat="forever" to fade and loop instead. Your own pointer is left alone. * * Wrap text in . Mark a phrase inside the * text with [[…]] to sweep just that; otherwise the whole text is swept. */ type Props = { /** Accent (hex): the arrow, the box and the swept text, unless set separately below. */ color?: string; /** "once" sweeps every target in turn, keeps each highlight, and stops for good; "forever" fades each one and loops. */ repeat?: "once" | "forever"; /** Older switch for the same thing: true is "forever", false is "once". `repeat` wins when both are given. */ loop?: boolean; /** Box background; defaults to the accent at 16%. */ fill?: string; /** Box edge; defaults to the accent at 48%. */ ring?: string; /** Colour the swept words turn; defaults to the accent. */ ink?: string; /** Arrow fill; defaults to the accent. */ arrow?: string; /** Space the box leaves either side of the words (px). */ padX?: number; /** Delay before the first sweep, once in view (ms). */ startDelay?: number; /** Drag speed in px per second. */ speed?: number; motion?: "full" | "reduced"; className?: string; style?: CSSProperties; children?: ReactNode; }; /** Text the cursor can sweep. `order` sets the sweep sequence; "\n" is a line break; [[…]] marks the phrase. */ export function SweepText({ text, order, as = "p", className = "", style }: { text: string; order?: number; as?: keyof HTMLElementTagNameMap; className?: string; style?: CSSProperties }) { const marked = text.includes("[["); let inside = !marked; const rows = text.split("\n"); return createElement( as, { className, style, "data-hs-text": order ?? "" }, rows.map((row, r) => ( {r > 0 &&
} {row.split(" ").map((raw, i) => { let w = raw; if (w.startsWith("[[")) { inside = true; w = w.slice(2); } let end = false; if (w.includes("]]")) { end = true; w = w.replace("]]", ""); } const t = inside; if (end) inside = false; return ( {i > 0 && " "} {w} ); })}
)), ); } type Line = { words: HTMLElement[]; lefts: number[]; widths: number[]; x: number; y: number; w: number; h: number }; type Step = { ms: number; run: (t: number) => void; done?: () => void }; const PAD_Y = 2; const ARROW = "M5.77 3.24 L28.12 10.61 A3 3 0 0 1 28.07 16.33 L20.61 18.63 A3 3 0 0 0 18.63 20.61 L16.33 28.07 A3 3 0 0 1 10.61 28.12 L3.24 5.77 A2 2 0 0 1 5.77 3.24 Z"; const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); const easeSine = (t: number) => -(Math.cos(Math.PI * t) - 1) / 2; const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; export function HighlightSweep({ color = "#5fd4bf", repeat, loop, fill, ring, ink, arrow, padX = 6, startDelay = 500, speed = 380, motion = "full", className = "", style, children }: Props) { const hostRef = useRef(null); const underRef = useRef(null); const arrowRef = useRef(null); const [reduced, setReduced] = useState(motion === "reduced"); const forever = repeat ? repeat === "forever" : loop === true; useEffect(() => { const rm = matchMedia("(prefers-reduced-motion: reduce)"); const set = () => setReduced(motion === "reduced" || rm.matches); set(); rm.addEventListener("change", set); return () => rm.removeEventListener("change", set); }, [motion]); useEffect(() => { const host = hostRef.current, under = underRef.current, arr = arrowRef.current; if (!host || !under || !arr) return; const PAD_X = padX; let raf = 0, lastT = 0, visible = false, alive = true, finished = false; let steps: Step[] = [], si = 0, el = 0, target = 0; // Boxes are numbered across targets, so a kept highlight never shares a box with the next one. let boxes: HTMLDivElement[] = [], base = 0; const cur = { x: 0, y: 0, o: 0, down: false }; const scale = () => { const r = host.getBoundingClientRect(); return { r, s: r.width / host.offsetWidth || 1 }; }; const targets = () => Array.from(host.querySelectorAll("[data-hs-text]")) .filter((b) => b.dataset.hsText !== "") .sort((a, b) => Number(a.dataset.hsText) - Number(b.dataset.hsText)); /** The visual lines of a target's marked words, in host-local px. */ const measure = (block: HTMLElement): Line[] => { const { r, s } = scale(); const lines: Line[] = []; let L: Line | null = null; block.querySelectorAll(".hs__w[data-t]").forEach((w) => { const b = w.getBoundingClientRect(); const x0 = (b.left - r.left) / s, y0 = (b.top - r.top) / s, x1 = (b.right - r.left) / s, y1 = (b.bottom - r.top) / s; if (!L || Math.abs(y0 - L.y) > (y1 - y0) * 0.5) { L = { words: [], lefts: [], widths: [], x: x0, y: y0, w: 0, h: y1 - y0 }; lines.push(L); } L.words.push(w); L.lefts.push(x0); L.widths.push(x1 - x0); L.w = x1 - L.x; L.h = Math.max(L.h, y1 - L.y); }); return lines; }; const drawArrow = () => { arr.style.transform = `translate3d(${cur.x.toFixed(2)}px, ${cur.y.toFixed(2)}px, 0)`; arr.style.opacity = cur.o.toFixed(3); if (cur.down) arr.dataset.down = ""; else delete arr.dataset.down; }; const box = (i: number) => { while (boxes.length <= i) { const b = document.createElement("div"); b.className = "hs__box"; under.appendChild(b); boxes.push(b); } return boxes[i]; }; /** Grow a line's box (box number n) and its words' accent edge to x (host px). */ const fillLine = (l: Line, n: number, x: number) => { const b = box(n); const left = l.x - PAD_X; const w = Math.max(0, Math.min(l.w + PAD_X * 2, x - left)); b.style.transform = `translate3d(${left.toFixed(2)}px, ${(l.y - PAD_Y).toFixed(2)}px, 0)`; b.style.width = `${w.toFixed(2)}px`; b.style.height = `${(l.h + PAD_Y * 2).toFixed(2)}px`; b.dataset.on = ""; l.words.forEach((wd, k) => wd.style.setProperty("--f", `${Math.max(0, Math.min(l.widths[k] + 2, x - l.lefts[k])).toFixed(2)}px`)); }; const clearAll = () => { boxes.forEach((b) => b.remove()); boxes = []; base = 0; host.querySelectorAll(".hs__w").forEach((w) => { w.style.removeProperty("--f"); w.removeAttribute("data-fade"); }); }; const restAfter = (last: Line) => ({ x: last.x + last.w + PAD_X + 6, y: last.y + last.h + PAD_Y + 10 }); /** Build the timeline for one target. `cont` means the arrow is already on screen and glides on from where it is. */ const plan = (block: HTMLElement, cont: boolean, isLast: boolean) => { const lines = measure(block); if (!lines.length) return []; const hostW = host.offsetWidth, hostH = host.offsetHeight; const mid = (l: Line) => l.y + l.h * 0.58; const first = lines[0], last = lines[lines.length - 1]; const sx = first.x - 2, sy = mid(first); const from = cont ? { x: cur.x, y: cur.y } : { x: Math.min(hostW - 20, sx + 260), y: Math.min(hostH - 10, sy + 170) }; const ctrl = cont ? { x: (from.x + sx) / 2, y: Math.max(from.y, sy) + 40 } : { x: from.x - 40, y: sy + 30 }; const endX = last.x + last.w + PAD_X; const rest = restAfter(last); const away = { x: rest.x + 70, y: rest.y + 60 }; const at = base; const out: Step[] = []; out.push({ ms: 900, run: (t) => { const e = ease(t); cur.x = (1 - e) * (1 - e) * from.x + 2 * (1 - e) * e * ctrl.x + e * e * sx; cur.y = (1 - e) * (1 - e) * from.y + 2 * (1 - e) * e * ctrl.y + e * e * sy; cur.o = cont ? 1 : Math.min(1, t * 4); } }); out.push({ ms: 160, run: () => { cur.down = true; }, done: () => fillLine(first, at, first.x - PAD_X + 2) }); lines.forEach((l, i) => { const x0 = i === 0 ? sx : l.x - PAD_X; const x1 = l.x + l.w + PAD_X; const ms = Math.max(i === 0 && lines.length === 1 ? 900 : 520, ((x1 - x0) / speed) * 1000); // One continuous drag: ease in on the first line, steady through the middle, ease out on the last. const fn = lines.length === 1 ? ease : i === 0 ? (t: number) => t * t * (2 - t) : i === lines.length - 1 ? (t: number) => t * (1 + t - t * t) : (t: number) => t; out.push({ ms, run: (t) => { const e = fn(t); cur.x = lerp(x0, x1, e); cur.y = mid(l); fillLine(l, at + i, cur.x); } }); if (i < lines.length - 1) { const n = lines[i + 1]; out.push({ ms: 280, run: (t) => { const e = easeSine(t); cur.x = lerp(x1, n.x - PAD_X, e); cur.y = lerp(mid(l), mid(n), e); fillLine(l, at + i, x1); fillLine(n, at + i + 1, Math.max(n.x - PAD_X, cur.x)); } }); } }); out.push({ ms: 140, run: () => { cur.down = false; } }); const end = { x: endX, y: mid(last) }; out.push({ ms: 520, run: (t) => { const e = easeOut(t); cur.x = lerp(end.x, rest.x, e); cur.y = lerp(end.y, rest.y, e); }, done: () => { base = at + lines.length; } }); if (!forever) { // Once: the highlight stays. Pause on it, then glide on to the next phrase; after the last, rest for good. if (!isLast) out.push({ ms: 900, run: () => {} }); return out; } out.push({ ms: 2400, run: () => {} }); out.push({ ms: 420, run: (t) => { boxes.forEach((b) => (b.style.opacity = String(1 - t))); if (t > 0) host.querySelectorAll(".hs__w").forEach((w) => w.style.getPropertyValue("--f") && w.setAttribute("data-fade", "")); const e = ease(t); cur.x = lerp(rest.x, away.x, e); cur.y = lerp(rest.y, away.y, e); cur.o = 1 - t; }, done: clearAll }); out.push({ ms: 1200, run: () => {} }); return out; }; const begin = (i: number) => { const all = targets(); if (!all.length) return false; if (i >= all.length) { if (!forever) return false; i = 0; } const cont = !forever && i > 0; target = i; if (forever) clearAll(); steps = plan(all[i], cont, i === all.length - 1); si = 0; el = 0; return steps.length > 0; }; /** Highlights for targets [0, upto) drawn complete, with the arrow resting past the last of them. */ const settled = (upto: number) => { clearAll(); const all = targets().slice(0, upto); let lastLine: Line | undefined; all.forEach((t) => { const lines = measure(t); lines.forEach((l, i) => fillLine(l, base + i, l.x + l.w + PAD_X)); base += lines.length; lastLine = lines[lines.length - 1] ?? lastLine; }); if (lastLine) { const r = restAfter(lastLine); cur.x = r.x; cur.y = r.y; cur.o = 1; cur.down = false; drawArrow(); } }; const tick = (now: number) => { raf = 0; if (!alive || finished || !visible || document.hidden) { lastT = 0; return; } const dt = lastT ? Math.min(50, now - lastT) : 16; lastT = now; el += dt; while (si < steps.length && el >= steps[si].ms) { steps[si].run(1); steps[si].done?.(); el -= steps[si].ms; si++; } if (si >= steps.length) { if (!begin(target + 1)) { finished = true; drawArrow(); return; } } else steps[si].run(el / steps[si].ms); drawArrow(); raf = requestAnimationFrame(tick); }; const wake = () => { if (!raf && alive && !finished) raf = requestAnimationFrame(tick); }; if (reduced) { // Still frame: the finished state (every phrase when it plays once, the first when it loops). const draw = () => settled(forever ? 1 : targets().length); draw(); const ro = new ResizeObserver(draw); ro.observe(host); document.fonts?.ready.then(() => alive && draw()); return () => { alive = false; ro.disconnect(); clearAll(); }; } // A short wait before the first sweep, counted only while visible. steps = [{ ms: startDelay, run: () => {} }]; si = 0; el = 0; target = -1; cur.o = 0; drawArrow(); const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) wake(); }, { threshold: 0.2 }); io.observe(host); const onVis = () => { if (!document.hidden) wake(); }; document.addEventListener("visibilitychange", onVis); // Reflow: redraw what is already done and start the current target again with fresh measurements. let w0 = host.offsetWidth; const ro = new ResizeObserver(() => { if (host.offsetWidth === w0) return; w0 = host.offsetWidth; if (finished) return settled(targets().length); if (target < 0) return; if (forever) { begin(target); cur.o = 0; drawArrow(); return; } settled(target); begin(target); drawArrow(); }); ro.observe(host); return () => { alive = false; cancelAnimationFrame(raf); raf = 0; io.disconnect(); ro.disconnect(); document.removeEventListener("visibilitychange", onVis); clearAll(); }; }, [reduced, forever, startDelay, speed, padX]); const vars: Record = { "--hs-c": color }; if (fill) vars["--hs-fill"] = fill; if (ring) vars["--hs-ring"] = ring; if (ink) vars["--hs-ink"] = ink; if (arrow) vars["--hs-arrow"] = arrow; return (
); }