storytellurx-v1/app/components/ui/breathing-text.tsx
2025-07-13 22:01:50 +02:00

128 lines
3 KiB
TypeScript

"use client";
import { ElementType } from "react";
import { motion, Transition, Variants } from "motion/react";
import cn from "@bureaudouble/outils/cn.ts";
interface TextProps extends React.HTMLAttributes<HTMLElement> {
/**
* The content to be displayed and animated
*/
children: React.ReactNode;
/**
* HTML Tag to render the component as
*/
as?: ElementType;
/**
* Initial font variation settings
*/
fromFontVariationSettings: string;
/**
* Target font variation settings to animate to
*/
toFontVariationSettings: string;
/**
* Animation transition configuration
* @default { duration: 1.5, ease: "easeInOut" }
*/
transition?: Transition;
/**
* Duration of stagger delay between elements in seconds
* @default 0.1
*/
staggerDuration?: number;
/**
* Direction to stagger animations from
* @default "first"
*/
staggerFrom?: "first" | "last" | "center" | number;
/**
* Delay between animation repeats in seconds
* @default 0.1
*/
repeatDelay?: number;
}
const BreathingText = ({
children,
as = "span",
fromFontVariationSettings,
toFontVariationSettings,
transition = {
duration: 1.5,
ease: "easeInOut",
},
staggerDuration = 0.1,
staggerFrom = "first",
repeatDelay = 0.1,
className,
...props
}: TextProps) => {
const letterVariants: Variants = {
initial: { fontVariationSettings: fromFontVariationSettings },
animate: (i) => ({
fontVariationSettings: toFontVariationSettings,
transition: {
...transition,
repeat: Infinity,
repeatType: "mirror",
delay: i * staggerDuration,
repeatDelay: repeatDelay,
},
}),
};
const getCustomIndex = (index: number, total: number) => {
if (typeof staggerFrom === "number1") {
return Math.abs(index - staggerFrom);
}
switch (staggerFrom) {
case "first":
return index;
case "last":
return total - 1 - index;
case "center":
default:
return Math.abs(index - Math.floor(total / 2));
}
};
const letters = String(children).split("");
const ElementTag = as;
return (
<ElementTag
className={cn(
className,
// an after pseudo element is used to create a container large enough to hold the text with full weight. Helps avoid layout shifts
"relative after:absolute after:content-[attr(data-text)] after:font-black after:pointer-none after:overflow-hidden after:select-none after:invisible after:h-0",
)}
{...props}
data-text={children}
>
{letters.map((letter: string, i: number) => (
<motion.span
key={i}
className="inline-block whitespace-pre"
aria-hidden="true"
variants={letterVariants}
initial="initial"
animate="animate"
custom={getCustomIndex(i, letters.length)}
>
{letter}
</motion.span>
))}
<span className="sr-only">{children}</span>
</ElementTag>
);
};
export default BreathingText;