A highlighter swipe that sweeps in behind text when it scrolls into view.
npx shadcn@latest add @paragon/text-highlight"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface TextHighlightProps extends React.ComponentProps<"span"> {
/** Milliseconds before the swipe starts once the text is in view. */
delay?: number;
/** Sweep once and stay, or re-hide when scrolled back out. */
once?: boolean;
/** Classes for the highlight layer — override to recolor the swipe. */
highlightClassName?: string;
/** Render the highlight fully shown with no motion. */
static?: boolean;
}
/**
* A highlighter swipe that sweeps in behind text when it scrolls into view.
*
* Inline element — wrap a phrase inside your own heading or paragraph. The
* swipe is a decorative layer revealed by transitioning `clip-path` from
* `inset(0 100% 0 0)` to `inset(0 0 0 0)` (a left-origin sweep) on ease-out,
* so the text stays selectable and readable throughout. A CSS transition (not
* a keyframe) keeps the sweep interruptible when `once={false}`. This is a
* page-level reveal, so it uses the marketing duration (`--duration-slow`);
* the exit runs at roughly half on `--ease-exit`. Reduced motion shows the
* highlight statically.
*
* Meant for short phrases: the swipe keeps the phrase on one line
* (`whitespace-nowrap`) so the layer always hugs the text.
*/
export function TextHighlight({
delay = 0,
once = true,
highlightClassName,
static: isStatic = false,
className,
children,
...props
}: TextHighlightProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once, amount: 0.5 });
const reducedMotion = useReducedMotion() ?? false;
// Reduced motion and static renders show the highlight without any sweep.
const noMotion = isStatic || reducedMotion;
const shown = noMotion || inView;
return (
<span
ref={ref}
className={cn("relative isolate whitespace-nowrap", className)}
{...props}
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-[-0.14em] inset-y-[-0.08em] -z-10 rounded-[0.25em] bg-amber-200/80 dark:bg-amber-400/25",
highlightClassName,
)}
style={{
clipPath: shown ? "inset(0 0 0 0)" : "inset(0 100% 0 0)",
transitionProperty: noMotion ? "none" : "clip-path",
transitionDuration: shown
? "var(--duration-slow)"
: "var(--duration-base)",
transitionTimingFunction: shown
? "var(--ease-out)"
: "var(--ease-exit)",
transitionDelay: shown ? `${delay}ms` : "0ms",
}}
/>
{children}
</span>
);
}