Components

Highlight Text

A component to highlight text with animated SVG shapes like underlines, boxes, circles, and markers.

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