"use client" import React, { memo, useCallback, useEffect, useMemo, useRef, type ElementType, } from "react" import { useAnimate, type AnimationOptions, type ValueAnimationTransition, } from "motion/react" import { cn } from "@/lib/utils" const HAS_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl const splitIntoCharacters = (text: string): string[] => { if (HAS_SEGMENTER) { const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }) return Array.from(segmenter.segment(text), ({ segment }) => segment) } return Array.from(text) } const extractTextFromChildren = (children: React.ReactNode): string => { if (children == null) return "" if (typeof children === "string") return children if (typeof children === "number") return String(children) if (Array.isArray(children)) { return children.map(extractTextFromChildren).join("") } if (React.isValidElement(children)) { const props = children.props as Record const childText = props.children as React.ReactNode if (childText != null) { return extractTextFromChildren(childText) } } return "" } const ROTATION_MAP = { top: "rotateX(90deg)", right: "rotateY(90deg)", bottom: "rotateX(-90deg)", left: "rotateY(-90deg)", } as const const DEFAULT_TRANSITION: ValueAnimationTransition = { type: "spring", damping: 30, stiffness: 300, } interface Text3DFlipProps { children: React.ReactNode as?: ElementType className?: string textClassName?: string flipTextClassName?: string staggerDuration?: number staggerFrom?: "first" | "last" | "center" | number | "random" transition?: ValueAnimationTransition | AnimationOptions rotateDirection?: "top" | "right" | "bottom" | "left" } const Text3DFlip = ({ children, as: ElementTag = "p", className, textClassName, flipTextClassName, staggerDuration = 0.05, staggerFrom = "first", transition = DEFAULT_TRANSITION, rotateDirection = "right", ...props }: Text3DFlipProps) => { const isAnimatingRef = useRef(false) const isMountedRef = useRef(false) const [scope, animate] = useAnimate() const rotationTransform = ROTATION_MAP[rotateDirection] useEffect(() => { isMountedRef.current = true return () => { isMountedRef.current = false isAnimatingRef.current = false } }, []) const text = useMemo(() => { try { return extractTextFromChildren(children) } catch { return "" } }, [children]) const characters = useMemo(() => { const words = text.split(" ") return words.map((word, i) => ({ characters: splitIntoCharacters(word), needsSpace: i !== words.length - 1, })) }, [text]) const charOffsets = useMemo(() => { const offsets = [0] for (const word of characters) { offsets.push(offsets.at(-1)! + word.characters.length) } return offsets }, [characters]) const getStaggerDelay = useCallback( (index: number, totalChars: number) => { if (staggerFrom === "first") return index * staggerDuration if (staggerFrom === "last") return (totalChars - 1 - index) * staggerDuration if (staggerFrom === "center") { const center = Math.floor(totalChars / 2) return Math.abs(center - index) * staggerDuration } if (staggerFrom === "random") { const randomIndex = Math.floor(Math.random() * totalChars) return Math.abs(randomIndex - index) * staggerDuration } return Math.abs(staggerFrom - index) * staggerDuration }, [staggerFrom, staggerDuration] ) const handleHoverStart = useCallback(async () => { if (isAnimatingRef.current) return isAnimatingRef.current = true try { const totalChars = characters.reduce( (sum, word) => sum + word.characters.length, 0 ) const delays = Array.from({ length: totalChars }, (_, i) => getStaggerDelay(i, totalChars) ) await animate( ".text-3d-flip-char", { transform: rotationTransform }, { ...transition, delay: (i: number) => delays[i], } ) if (!isMountedRef.current) return await animate( ".text-3d-flip-char", { transform: "rotateX(0deg) rotateY(0deg)" }, { duration: 0 } ) } finally { if (isMountedRef.current) { isAnimatingRef.current = false } } }, [characters, transition, getStaggerDelay, rotationTransform, animate]) return ( {text} {characters.map((wordObj, wordIndex) => ( {wordObj.characters.map((char, charIndex) => ( ))} {wordObj.needsSpace && } ))} ) } interface CharBoxProps { char: string textClassName?: string flipTextClassName?: string rotateDirection: "top" | "right" | "bottom" | "left" } const SECOND_FACE_TRANSFORMS = { top: "rotateX(-90deg) translateZ(0.5lh)", right: "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg) translateX(50%)", bottom: "rotateX(90deg) translateZ(0.5lh)", left: "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(50%)", } as const const FRONT_FACE_TRANSFORMS = { top: "translateZ(0.5lh)", bottom: "translateZ(0.5lh)", left: "rotateY(90deg) translateX(50%) rotateY(-90deg)", right: "rotateY(-90deg) translateX(50%) rotateY(90deg)", } as const const CONTAINER_TRANSFORMS = { top: "translateZ(-0.5lh)", bottom: "translateZ(-0.5lh)", left: "rotateY(90deg) translateX(50%) rotateY(-90deg)", right: "rotateY(90deg) translateX(50%) rotateY(-90deg)", } as const const CharBox = memo( ({ char, textClassName, flipTextClassName, rotateDirection, }: CharBoxProps) => ( {char} {char} ) ) CharBox.displayName = "CharBox" Text3DFlip.displayName = "Text3DFlip" export default Text3DFlip