Text Effects

Text Highlight

A highlighter swipe that sweeps in behind text when it scrolls into view.

Install

npx shadcn@latest add @paragon/text-highlight

text-highlight.tsx

"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>
  );
}