Animation
Highlighter
Hand-drawn highlight, underline, box or circle marks that draw on mount or scroll.
npx bezel-add add highlighterpackages/ui/src/animation/Highlighter.tsx · 120 lines
"use client";
import { useLayoutEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
import { useInView } from "motion/react";
import { annotate } from "rough-notation";
type AnnotationAction =
| "highlight"
| "underline"
| "box"
| "circle"
| "strike-through"
| "crossed-off"
| "bracket";
export type HighlighterProps = {
children: ReactNode;
action?: AnnotationAction;
color?: string;
strokeWidth?: number;
animationDuration?: number;
iterations?: number;
padding?: number;
multiline?: boolean;
/** When true, only draw once the span scrolls into view. */
isView?: boolean;
/**
* Change this value to draw the mark again from the start, for a replay
* button or a step that returns. Under reduced motion the finished mark
* simply stays in place.
*/
replayKey?: string | number;
};
const RM_QUERY = "(prefers-reduced-motion: reduce)";
const subscribeReducedMotion = (onChange: () => void) => {
const query = window.matchMedia(RM_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const readReducedMotion = () => window.matchMedia(RM_QUERY).matches;
const serverReducedMotion = () => false;
/** Where the words sit relative to the box the mark is drawn in. */
function placement(element: HTMLElement) {
const frame = (element.offsetParent ?? document.body).getBoundingClientRect();
return Array.from(element.getClientRects(), (r) =>
[r.left - frame.left, r.top - frame.top, r.width, r.height].map(Math.round).join(","),
).join("|");
}
/**
* Hand-drawn rough-notation mark that draws on mount (or on scroll into view),
* and again whenever `replayKey` changes.
*
* Each draw is a fresh annotation: the previous one is removed and a new one
* animates in. When layout moves the words afterwards, the finished mark is
* redrawn in place without animating, so a resize never replays it. Reduced
* motion draws the same finished mark with no animation, and follows changes
* to the setting live.
*/
export function Highlighter({
children,
action = "highlight",
color = "#ffd1dc",
strokeWidth = 1.5,
animationDuration = 600,
iterations = 2,
padding = 2,
multiline = true,
isView = false,
replayKey,
}: HighlighterProps) {
const elementRef = useRef<HTMLSpanElement>(null);
const isInView = useInView(elementRef, { once: true, margin: "-10%" });
const shouldShow = !isView || isInView;
const reduced = useSyncExternalStore(subscribeReducedMotion, readReducedMotion, serverReducedMotion);
// Under reduced motion the mark is already final, so a replay has nothing to redraw.
const drawKey = reduced ? null : replayKey;
useLayoutEffect(() => {
const element = elementRef.current;
if (!shouldShow || !element) return;
const annotation = annotate(element, {
type: action,
color,
strokeWidth,
animate: !reduced,
animationDuration,
iterations,
padding,
multiline,
});
annotation.show();
// Showing an annotation that is already showing redraws it without
// animation, which is what a layout change needs.
let last = placement(element);
const resizeObserver = new ResizeObserver(() => {
const next = placement(element);
if (next === last) return;
last = next;
if (annotation.isShowing()) annotation.show();
});
resizeObserver.observe(element);
resizeObserver.observe(document.body);
return () => {
resizeObserver.disconnect();
annotation.remove();
};
}, [shouldShow, reduced, drawKey, action, color, strokeWidth, animationDuration, iterations, padding, multiline]);
return (
<span ref={elementRef} className="relative inline bg-transparent">
{children}
</span>
);
}