useLoop hook
Design
loop-text.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useCallback, useEffect, useMemo, useState } from "react";
export function useLoop(delay = 1000) {
const [key, setKey] = useState(0);
const incrementKey = useCallback(() => {
setKey((prev) => prev + 1);
}, []);
useEffect(() => {
const id = window.setInterval(incrementKey, delay);
return () => window.clearInterval(id);
}, [delay, incrementKey]);
return { key };
}
export interface LoopTextProps {
items: string[];
delay?: number;
duration?: number;
className?: string;
textClassName?: string;
caption?: string;
}
const DEFAULT_TEXT_CLASS =
"font-display uppercase leading-none tracking-tight text-ink text-[clamp(2.25rem,8vw,5rem)] whitespace-nowrap";
export default function LoopText({
items,
delay = 1000,
duration = 0.3,
className = "",
textClassName = DEFAULT_TEXT_CLASS,
caption = "useLoop hook",
}: LoopTextProps) {
const reduce = useReducedMotion();
const list = useMemo(() => (items.length > 0 ? items : [""]), [items]);
const { key } = useLoop(delay);
const current = list[key % list.length];
if (reduce) {
return (
<div className={className}>
<p className={textClassName}>{list[0]}</p>
</div>
);
}
return (
<div className={"flex flex-col items-center justify-center gap-8 p-8 " + className}>
{caption !== "" && (
<div className="mb-20 grid content-start justify-items-center gap-6 text-center">
<span className="relative max-w-[12ch] text-xs uppercase leading-tight text-ink/40 after:absolute after:left-1/2 after:top-full after:h-16 after:w-px after:-translate-x-1/2 after:bg-gradient-to-b after:from-ink/40 after:to-transparent after:content-['']">
{caption}
</span>
</div>
)}
<AnimatePresence mode="popLayout">
<motion.h1
key={key}
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: "-100%" }}
transition={{ duration }}
className={textClassName}
>
{current}
</motion.h1>
</AnimatePresence>
</div>
);
}