Split Text Effect

PreviousNext

A split text effect component for Next.js apps with next-themes and Tailwind CSS, supporting system, light, and dark modes.

Docs
buchariteshui

Preview

Loading preview…
registry/bucharitesh/split-text-effect.tsx
'use client';

import { cn } from '@/lib/utils';
import { motion, useSpring, useTransform } from 'motion/react';
import * as React from 'react';

interface CrossProps extends React.HTMLAttributes<HTMLDivElement> {
  position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
  color?: string;
}

const Cross = React.forwardRef<HTMLDivElement, CrossProps>(
  ({ position, className, color, ...props }, ref) => {
    const positionClasses = {
      'top-left': '-top-px -left-px rotate-0',
      'top-right': '-top-px -right-px rotate-90',
      'bottom-left': 'bottom-[-2px] -left-px -rotate-90',
      'bottom-right': 'bottom-[-2px] -right-px -rotate-180',
    };

    return (
      <div
        ref={ref}
        aria-hidden="true"
        className={cn(
          'absolute h-[15px] w-[15px] cursor-pointer',
          positionClasses[position],
          className
        )}
        data-position={position}
        {...props}
      >
        <div
          className="absolute top-0 left-0 h-px w-[15px]"
          style={{ backgroundColor: color }}
        />
        <div
          className="absolute bottom-0 left-0 h-[15px] w-px"
          style={{ backgroundColor: color }}
        />
      </div>
    );
  }
);
Cross.displayName = 'Cross';

interface SplitTextEffectProps extends React.HTMLAttributes<HTMLDivElement> {
  text: string | React.ReactNode;
  fill?: number;
  accent?: string;
}

const SplitTextEffect = React.forwardRef<HTMLDivElement, SplitTextEffectProps>(
  ({ text, fill = 0.5, accent = '#006efe', className, ...props }, ref) => {
    const containerRef = React.useRef<HTMLDivElement>(null);
    const lineRef = React.useRef<HTMLDivElement>(null);
    const [hasMounted, setHasMounted] = React.useState(false);

    React.useEffect(() => {
      setHasMounted(true);
    }, []);

    const smoothY = useSpring(0, {
      stiffness: 100,
      damping: 20,
    });

    React.useEffect(() => {
      if (!hasMounted || !containerRef.current) return;

      const container = containerRef.current;
      const height = container.offsetHeight;
      const initialY = Math.min(
        Math.max(height * (1 - fill), height * 0.1),
        height * 0.9
      );

      smoothY.set(initialY);
    }, [hasMounted, fill]);

    const handleMouseMove = (e: React.MouseEvent) => {
      if (!containerRef.current) return;
      const rect = containerRef.current.getBoundingClientRect();
      const height = rect.height;

      // Calculate y position and clamp between 20% and 80% of height
      const rawY = e.clientY - rect.top;
      const clampedY = Math.min(Math.max(rawY, height * 0.1), height * 0.9);
      smoothY.set(clampedY);
    };

    const handleMouseLeave = () => {
      if (!containerRef.current) return;
      const height = containerRef.current.offsetHeight;
      // Reset to initial fill position, but respect the 20%-80% bounds
      const resetY = Math.min(
        Math.max(height * (1 - fill), height * 0.1),
        height * 0.9
      );
      smoothY.set(resetY);
    };

    return (
      <div
        ref={containerRef}
        className={cn(
          'relative flex h-full w-full items-center justify-center bg-white p-20 text-5xl dark:bg-black',
          className
        )}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        {...props}
      >
        <Cross position="top-left" color={accent} />
        <Cross position="top-right" color={accent} />
        <Cross position="bottom-left" color={accent} />
        <Cross position="bottom-right" color={accent} />

        <div className="z-0 flex h-full w-full items-center justify-center text-black dark:text-white">
          {text}
        </div>

        <motion.div
          ref={lineRef}
          aria-hidden="true"
          className="absolute inset-0 z-20 h-1 select-none border-t-white dark:border-t-black"
          style={{
            opacity: 1,
            y: smoothY,
            borderTopWidth: '2px',
            borderBottomWidth: '2px',
            borderBottomColor: accent,
          }}
        />

        <motion.div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 bottom-0 left-0 z-2 flex select-none items-center justify-center"
          style={{
            opacity: 1,
            clipPath: useTransform(
              smoothY,
              (value) => `inset(${value}px 0 0 0)`
            ),
          }}
        >
          <div
            className="absolute inset-0"
            style={{
              background: `linear-gradient(180deg, ${accent} 0, transparent 100%)`,
            }}
          />
          <div
            className="text-white dark:text-black"
            style={{
              textShadow: `-1px -1px 0 ${accent}, 1px -1px 0 ${accent}, -1px 1px 0 ${accent}, 1px 1px 0 ${accent}`,
            }}
          >
            {text}
          </div>
        </motion.div>
      </div>
    );
  }
);
SplitTextEffect.displayName = 'SplitTextEffect';

export { SplitTextEffect };

Installation

npx shadcn@latest add @bucharitesh/split-text-effect

Usage

import { SplitTextEffect } from "@/components/ui/split-text-effect"
<SplitTextEffect />