128 lines
3 KiB
TypeScript
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;
|