Text Modifier

PreviousNext

text highlighter with solid background and decorative markers for emphasis.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/text-modifier.tsx
"use client";
import React, { useRef, useEffect, useState } from "react";
import { motion } from "framer-motion";

interface TextModifierProps extends React.HTMLAttributes<HTMLDivElement> {
  highlightColorClass?: string;
  markerColorClass?: string;
  opacity?: number;
  animationDuration?: number;
  animationDelay?: number;
  animate?: boolean;
  triggerOnView?: boolean;
  repeat?: boolean;
  padding?: string;
}

const TextModifier: React.FC<TextModifierProps> = ({
  children,
  highlightColorClass = "bg-yellow-200",
  markerColorClass = "bg-yellow-500",
  opacity = 0.8,
  animationDuration = 0.6,
  animationDelay = 0,
  animate = true,
  triggerOnView = true,
  repeat = false,
  padding = "0.125rem 0.375rem",
  className,
  ...props
}) => {
  const [isVisible, setIsVisible] = useState(!triggerOnView);
  const textRef = useRef<HTMLSpanElement>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    if (!triggerOnView || !textRef.current) return;
    observerRef.current = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          if (!repeat && observerRef.current) observerRef.current.disconnect();
        } else if (repeat) setIsVisible(false);
      },
      { threshold: 0.1, rootMargin: "-50px" }
    );
    observerRef.current.observe(textRef.current);
    return () => observerRef.current?.disconnect();
  }, [triggerOnView, repeat]);

  const shouldAnimate = animate && isVisible;
  const markerSize = 8;

  const renderMarkers = () => {
    const lineLength = 25;
    const offset = 4;
    return (
      <>
        <motion.span
          className="absolute"
          style={{ top: "-9px", left: `-${offset}px` }}
          initial={{ opacity: 0, y: -5 }}
          animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
          transition={{
            duration: 0.3,
            delay: animationDelay + animationDuration * 0.8,
            ease: "easeOut",
          }}
        >
          <span
            className={`block rounded-full ${markerColorClass}`}
            style={{ width: `${markerSize}px`, height: `${markerSize}px` }}
          />
          <span
            className={`block ${markerColorClass}`}
            style={{ width: "2px", height: `${lineLength}px`, marginLeft: `${(markerSize - 2) / 2}px` }}
          />
        </motion.span>
        <motion.span
          className="absolute"
          style={{ bottom: "-9px", right: `-${offset}px` }}
          initial={{ opacity: 0, y:  chipsetpx 5 }}
          animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: 5 }}
          transition={{
            duration: 0.3,
            delay: animationDelay + animationDuration,
            ease: "easeOut",
          }}
        >
          <span
            className={`block ${markerColorClass}`}
            style={{ width: "2px", height: `${lineLength}px`, marginLeft: `${(markerSize - 2) / 2}px` }}
          />
          <span
            className={`block rounded-full ${markerColorClass}`}
            style={{ width: `${markerSize}px`, height: `${markerSize}px` }}
          />
        </motion.span>
      </>
    );
  };

  const textContent = (
    <span ref={textRef} className={`relative ${className}`}>
      {children}
    </span>
  );

  const content = (
    <span className="relative inline" style={{ padding }}>
      <motion.span
        className={`${highlightColorClass} rounded`}
        style={{
          opacity,
          boxDecorationBreak: "clone",
          WebkitBoxDecorationBreak: "clone",
          padding: "0.125rem 0.25rem",
          display: "inline",
        }}
        initial={{ opacity: 0 }}
        animate={shouldAnimate ? { opacity } : { opacity: 0 }}
        transition={{ duration: animationDuration, delay: animationDelay, ease: "easeOut" }}
      >
        {textContent}
      </motion.span>
      {renderMarkers()}
    </span>
  );

  return <div {...props}>{content}</div>;
};

export default TextModifier;

Installation

npx shadcn@latest add @scrollxui/text-modifier

Usage

import { TextModifier } from "@/components/text-modifier"
<TextModifier />