1"use client";2
3import React, { useRef } from "react";4import { motion, useInView, useReducedMotion, type Variants } from "motion/react";5import { cn } from "@/lib/utils";6
7export type TextRevealVariant = "blur" | "blur-sm" | "chars" | "mask" | "chat" | "pop";8
9export type TextRevealProps = {10 text: string;11 12131415161718 variant?: TextRevealVariant;19 20 trigger?: "mount" | "inView";21 22 delay?: number;23 24 delayPerWord?: number;25 26 duration?: number;27 as?: "span" | "div" | "p" | "h1" | "h2" | "h3";28 className?: string;29};30
31const EASE_OUT = [0.23, 1, 0.32, 1] as const;32
33function unitVariants(variant: TextRevealVariant, duration: number): Variants {34 const t = (extra: object = {}) => ({ duration, ease: EASE_OUT, ...extra });35 switch (variant) {36 case "chars":37 return {38 hidden: { opacity: 0, y: "0.35em", filter: "blur(6px)" },39 visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: t() },40 };41 case "mask":42 return {43 hidden: { y: "110%", rotate: 4 },44 visible: { y: 0, rotate: 0, transition: t({ duration: duration * 1.4 }) },45 };46 case "chat":47 return {48 hidden: { opacity: 0, x: "-0.3em", filter: "blur(2px)" },49 visible: { opacity: 1, x: 0, filter: "blur(0px)", transition: t() },50 };51 case "pop":52 return {53 hidden: { opacity: 0, scale: 0.6, y: "0.2em" },54 visible: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", bounce: 0.45, duration: duration * 1.6 } },55 };56 default:57 return {58 hidden: { opacity: 0, y: "0.3em", filter: "blur(8px)" },59 visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: t() },60 };61 }62}63
64export const TextReveal = ({65 text,66 variant = "blur",67 trigger = "mount",68 delay = 0,69 delayPerWord,70 duration = 0.5,71 as = "span",72 className,73}: TextRevealProps) => {74 const reduce = useReducedMotion();75 const ref = useRef<HTMLElement>(null);76 const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" });77 const Tag = motion[as] as typeof motion.span;78 const words = text.trim().split(/\s+/);79 const byChar = variant === "chars";80 const units = byChar ? text.replace(/\s+/g, " ").trim().length : words.length;81 82 const step = Math.min(delayPerWord ?? (byChar ? 0.025 : 0.06), 1.2 / Math.max(units, 1));83 const show = trigger === "mount" || inView;84
85 if (reduce) {86 const Plain = as;87 return (88 <Plain ref={ref as React.Ref<never>} className={className}>89 {text}90 </Plain>91 );92 }93
94 const variants = unitVariants(variant, duration);95
96 return (97 <Tag98 ref={ref}99 aria-label={text}100 initial="hidden"101 animate={show ? "visible" : "hidden"}102 transition={{ staggerChildren: step, delayChildren: delay }}103 className={cn("inline-block", className)}104 >105 {words.map((word, w) => {106 const space = w < words.length - 1 ? " " : "";107 if (byChar) {108 109 return (110 <span key={w} aria-hidden className="inline-block whitespace-nowrap">111 {[...(word + space)].map((ch, c) => (112 <motion.span key={c} variants={variants} className="inline-block whitespace-pre">113 {ch}114 </motion.span>115 ))}116 </span>117 );118 }119 if (variant === "mask") {120 return (121 <span key={w} aria-hidden className="inline-block overflow-hidden pb-[0.12em] align-top -mb-[0.12em]">122 <motion.span variants={variants} className="inline-block origin-bottom-left whitespace-pre">123 {word + space}124 </motion.span>125 </span>126 );127 }128 return (129 <motion.span key={w} aria-hidden variants={variants} className="inline-block whitespace-pre">130 {word + space}131 </motion.span>132 );133 })}134 </Tag>135 );136};