ComponentsText Animations

Highlight Text

Animated text highlighter for React. SVG underlines, boxes, circles, and marker effects. Draw attention to key words with hand-drawn style animations.

Last updated on

Edit on GitHub
Open in

Make your text stand out with animated highlights.

import { HighlightText } from "@/components/ui/highlight-text";
 
export function HighlightTextDemo() {
  return (
    <div className="flex flex-col items-center justify-center py-20">
      <h1 className="max-w-2xl text-center font-bold text-4xl leading-relaxed tracking-tight">
        Make your text <HighlightText>stand out</HighlightText> with animated
        highlights.
      </h1>
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/highlight-text"

Manual

Install the following dependencies:

npm install class-variance-authority

Copy and paste the following code into your project.

import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
 
const highlightVariants = cva("relative inline-block", {
  variants: {
    variant: {
      underline: "",
      box: "",
      circle: "",
      marker: "",
    },
    color: {
      primary:
        "[--highlight-color:hsl(var(--highlight-primary))] [&_path]:[stroke:var(--highlight-color)]",
      secondary:
        "[--highlight-color:hsl(var(--highlight-secondary))] [&_path]:[stroke:var(--highlight-color)]",
      accent:
        "[--highlight-color:hsl(var(--highlight-accent))] [&_path]:[stroke:var(--highlight-color)]",
      destructive:
        "[--highlight-color:hsl(var(--highlight-destructive))] [&_path]:[stroke:var(--highlight-color)]",
    },
  },
  defaultVariants: {
    variant: "underline",
    color: "primary",
  },
});
 
type HighlightColor = "primary" | "secondary" | "accent" | "destructive";
type HighlightVariant = "underline" | "box" | "circle" | "marker";
 
export interface HighlightTextProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color"> {
  children: React.ReactNode;
  variant?: HighlightVariant;
  color?: HighlightColor;
  animationDuration?: number;
  animationDelay?: number;
  strokeWidth?: number;
  animate?: boolean;
}
 
const HighlightText = React.forwardRef<HTMLSpanElement, HighlightTextProps>(
  (
    {
      className,
      variant = "underline",
      color = "primary",
      children,
      animationDuration = 0.8,
      animationDelay = 0,
      strokeWidth = 2,
      animate = true,
      ...props
    },
    ref,
  ) => {
    const [isVisible, setIsVisible] = React.useState(!animate);
    const [dimensions, setDimensions] = React.useState({ width: 0, height: 0 });
    const containerRef = React.useRef<HTMLSpanElement>(null);
 
    React.useEffect(() => {
      const element = containerRef.current;
      if (!element) return;
 
      const updateDimensions = () => {
        setDimensions({
          width: element.offsetWidth,
          height: element.offsetHeight,
        });
      };
 
      updateDimensions();
 
      const resizeObserver = new ResizeObserver(updateDimensions);
      resizeObserver.observe(element);
 
      return () => resizeObserver.disconnect();
    }, []);
 
    React.useEffect(() => {
      if (!animate) {
        setIsVisible(true);
        return;
      }
 
      const element = containerRef.current;
      if (!element) return;
 
      const observer = new IntersectionObserver(
        ([entry]) => {
          if (entry?.isIntersecting) {
            setIsVisible(true);
            observer.disconnect();
          }
        },
        { threshold: 0.5, rootMargin: "0px 0px -50px 0px" },
      );
 
      observer.observe(element);
      return () => observer.disconnect();
    }, [animate]);
 
    const renderHighlight = () => {
      const { width, height } = dimensions;
      if (width === 0 || height === 0) return null;
 
      const padding = 8;
      const svgWidth = width + padding * 2;
      const svgHeight = height + padding * 2;
 
      const baseStyles: React.CSSProperties = {
        position: "absolute",
        top: -padding,
        left: -padding,
        width: svgWidth,
        height: svgHeight,
        pointerEvents: "none",
        overflow: "visible",
      };
 
      const pathStyles: React.CSSProperties = {
        fill: "none",
        stroke: "var(--highlight-color)",
        strokeWidth,
        strokeLinecap: "round",
        strokeLinejoin: "round",
        transition: `stroke-dashoffset ${animationDuration}s cubic-bezier(0.65, 0, 0.35, 1) ${animationDelay}s`,
      };
 
      switch (variant) {
        case "underline": {
          const y = svgHeight - padding + 2;
          const pathLength = width + 10;
          const d = `M ${padding - 2} ${y} Q ${padding + width * 0.25} ${y - 3} ${padding + width * 0.5} ${y} T ${padding + width + 2} ${y}`;
 
          return (
            <svg style={baseStyles} aria-hidden="true">
              <path
                d={d}
                style={{
                  ...pathStyles,
                  strokeDasharray: pathLength,
                  strokeDashoffset: isVisible ? 0 : pathLength,
                }}
              />
            </svg>
          );
        }
 
        case "box": {
          const boxPadding = 4;
          const pathLength = (width + height + boxPadding * 2) * 2 + 20;
          const d = `
            M ${padding - boxPadding} ${padding - boxPadding + 2}
            L ${padding + width + boxPadding} ${padding - boxPadding}
            L ${padding + width + boxPadding + 2} ${padding + height + boxPadding}
            L ${padding - boxPadding + 1} ${padding + height + boxPadding + 2}
            Z
          `;
 
          return (
            <svg style={baseStyles} aria-hidden="true">
              <path
                d={d}
                style={{
                  ...pathStyles,
                  strokeDasharray: pathLength,
                  strokeDashoffset: isVisible ? 0 : pathLength,
                }}
              />
            </svg>
          );
        }
 
        case "circle": {
          const cx = padding + width / 2;
          const cy = padding + height / 2;
          const rx = width / 2 + 6;
          const ry = height / 2 + 6;
          const pathLength = Math.PI * 2 * Math.max(rx, ry);
 
          // Hand-drawn ellipse using bezier curves
          const d = `
            M ${cx - rx} ${cy}
            C ${cx - rx} ${cy - ry * 0.55} ${cx - rx * 0.55} ${cy - ry - 2} ${cx} ${cy - ry}
            C ${cx + rx * 0.55} ${cy - ry + 2} ${cx + rx + 1} ${cy - ry * 0.55} ${cx + rx} ${cy + 2}
            C ${cx + rx - 1} ${cy + ry * 0.55} ${cx + rx * 0.55} ${cy + ry + 1} ${cx - 2} ${cy + ry}
            C ${cx - rx * 0.55} ${cy + ry - 1} ${cx - rx + 2} ${cy + ry * 0.55} ${cx - rx} ${cy}
          `;
 
          return (
            <svg style={baseStyles} aria-hidden="true">
              <path
                d={d}
                style={{
                  ...pathStyles,
                  strokeDasharray: pathLength,
                  strokeDashoffset: isVisible ? 0 : pathLength,
                }}
              />
            </svg>
          );
        }
 
        case "marker": {
          const markerHeight = height + 4;
          const y1 = padding - 2;
          const _y2 = padding + markerHeight;
 
          return (
            <svg style={baseStyles} aria-hidden="true">
              <rect
                x={padding - 2}
                y={y1}
                width={width + 4}
                height={markerHeight}
                rx={2}
                style={{
                  fill: "var(--highlight-color)",
                  opacity: isVisible ? 0.3 : 0,
                  transition: `opacity ${animationDuration}s cubic-bezier(0.65, 0, 0.35, 1) ${animationDelay}s`,
                }}
              />
            </svg>
          );
        }
 
        default:
          return null;
      }
    };
 
    return (
      <span
        ref={(node) => {
          (
            containerRef as React.MutableRefObject<HTMLSpanElement | null>
          ).current = node;
          if (typeof ref === "function") {
            ref(node);
          } else if (ref) {
            ref.current = node;
          }
        }}
        className={cn(highlightVariants({ variant, color, className }))}
        {...props}
      >
        {renderHighlight()}
        <span className="relative z-10">{children}</span>
      </span>
    );
  },
);
 
HighlightText.displayName = "HighlightText";
 
export { HighlightText, highlightVariants };

Features

  • Multiple Variants: Choose from underline, box, circle, and marker styles.
  • Animated: Smooth SVG drawing animations when the component comes into view.
  • Customizable: Adjust colors, stroke width, animation duration, and delay.
  • Responsive: Automatically adjusts to the size of the text content.

Examples

Variants

Different styles of highlighting.

import { HighlightText } from "@/components/ui/highlight-text";
 
export function HighlightTextVariantsDemo() {
  return (
    <div className="flex flex-col items-center justify-center gap-12 py-10">
      <div className="font-medium text-2xl">
        This is an <HighlightText variant="underline">underline</HighlightText>{" "}
        highlight.
      </div>
      <div className="font-medium text-2xl">
        This is a <HighlightText variant="box">box</HighlightText> highlight.
      </div>
      <div className="font-medium text-2xl">
        This is a <HighlightText variant="circle">circle</HighlightText>{" "}
        highlight.
      </div>
      <div className="font-medium text-2xl">
        This is a <HighlightText variant="marker">marker</HighlightText>{" "}
        highlight.
      </div>
    </div>
  );
}

Colors

Different color themes for the highlights.

import { HighlightText } from "@/components/ui/highlight-text";
 
export function HighlightTextColorsDemo() {
  return (
    <div className="flex flex-col items-center justify-center gap-8 py-10">
      <div className="text-xl">
        Primary color:{" "}
        <HighlightText color="primary">Important text</HighlightText>
      </div>
      <div className="text-xl">
        Secondary color:{" "}
        <HighlightText color="secondary">Subtle detail</HighlightText>
      </div>
      <div className="text-xl">
        Accent color:{" "}
        <HighlightText color="accent">Special feature</HighlightText>
      </div>
      <div className="text-xl">
        Destructive color:{" "}
        <HighlightText color="destructive">Warning message</HighlightText>
      </div>
    </div>
  );
}

API Reference

HighlightText

Prop

Type

How is this guide?

On this page