Tidal Text Animation
idal Text Animation is an interactive UI component that animates text in a wave-like motion when users hover over elements. It combines smooth GSAP transitions with layered text masking to create a fluid, tidal effect. Ideal for showcasing names, categories, or highlights in a visually engaging way.
Installation
Install with CLI
Choose your package manager.
npx @praveenlodhi/elixir-ui add tidal-text-animationpnpm dlx @praveenlodhi/elixir-ui add tidal-text-animationyarn dlx @praveenlodhi/elixir-ui add tidal-text-animationbun x @praveenlodhi/elixir-ui add tidal-text-animationInstall dependencies
npm install gsap clsxpnpm add gsap clsxyarn add gsap clsxbun add gsap clsxCreate component file
Create a file at components/ui/tidal-text-animation.tsx and paste the Tidal
Text Animation source.
"use client";
import { useEffect, useRef } from "react";
import Image from "next/image";
import clsx from "clsx";
import gsap from "gsap";
export interface TidalTextAnimationProps {
items?: readonly {
label: string;
imageSrc: string;
}[];
fallbackText?: string;
className?: string;
}
const DEFAULT_ITEMS = [
{
label: "Aurora Skies",
imageSrc: "https://images.unsplash.com/photo-1501785888041-af3ef285b470",
},
{
label: "Desert Mirage",
imageSrc: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
},
{
label: "Ocean Drift",
imageSrc: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e",
},
{
label: "Mountain Echo",
imageSrc: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?2",
},
] as const;
const FALLBACK_TEXT = "Nature Vibes";
export function TidalTextAnimation({
items = [...DEFAULT_ITEMS],
fallbackText = FALLBACK_TEXT,
className,
}: TidalTextAnimationProps) {
const profileImagesRef = useRef<HTMLDivElement>(null);
const imagesRef = useRef<(HTMLDivElement | null)[]>([]);
const namesRef = useRef<(HTMLDivElement | null)[]>([]);
const headingsRef = useRef<(HTMLHeadingElement | null)[]>([]);
useEffect(() => {
const currentImages = imagesRef.current;
function splitTextManually(element: HTMLElement) {
const text = element.textContent || "";
element.innerHTML = "";
for (let i = 0; i < text.length; i++) {
const span = document.createElement("span");
span.classList.add("letter");
span.textContent = text.charAt(i);
element.appendChild(span);
}
return element.querySelectorAll(".letter");
}
headingsRef.current.forEach((heading) => {
if (heading) splitTextManually(heading);
});
const defaultHeading = headingsRef.current[0];
if (defaultHeading) {
const letters = defaultHeading.querySelectorAll(".letter");
gsap.set(letters, { y: "0%" });
}
if (window.innerWidth >= 900) {
currentImages.forEach((image, index) => {
if (!image) return;
const nameElement = namesRef.current[index + 1];
if (!nameElement) return;
const letters = nameElement.querySelectorAll(".letter");
image.addEventListener("mouseenter", () => {
gsap.to(letters, {
y: "-100%",
duration: 0.75,
stagger: { amount: 0.25, from: "center" },
ease: "power4.out",
});
});
image.addEventListener("mouseleave", () => {
gsap.to(letters, {
y: "0%",
duration: 0.75,
stagger: { amount: 0.25, from: "center" },
ease: "power4.out",
});
});
});
if (defaultHeading) {
const defaultLetters = defaultHeading.querySelectorAll(".letter");
currentImages.forEach((image) => {
if (!image) return;
image.addEventListener("mouseenter", () => {
gsap.to(defaultLetters, {
y: "100%",
duration: 0.75,
stagger: { amount: 0.25, from: "center" },
ease: "power4.out",
});
});
image.addEventListener("mouseleave", () => {
gsap.to(defaultLetters, {
y: "0%",
duration: 0.75,
stagger: { amount: 0.25, from: "center" },
ease: "power4.out",
});
});
});
}
}
}, []);
return (
<section
className={clsx(
"m-auto flex h-fit w-full max-w-4xl flex-col items-center justify-center gap-10 rounded-lg md:gap-3 md:rounded-xl md:p-5 lg:rounded-2xl",
className
)}
>
<style>{`
.letter {
display: inline-block;
}
`}</style>
{/* Images */}
<div
className="flex w-full flex-wrap items-center justify-center gap-4 md:gap-6"
ref={profileImagesRef}
>
{items.map((item, index) => (
<div
key={`${item.label}-${index}`}
className="relative z-1 aspect-9/16 h-30 origin-bottom cursor-pointer transition-transform duration-200 ease-out hover:scale-110 md:aspect-square md:size-30 md:hover:scale-115 lg:size-37.5"
ref={(el) => void (imagesRef.current[index] = el)}
>
<Image
src={item.imageSrc}
alt={item.label}
width={600}
height={600}
className="h-full w-full rounded-xl object-cover"
/>
</div>
))}
</div>
{/* Text Animation */}
<div className="font-barlow-condensed relative flex h-12.5 w-full items-center justify-center overflow-hidden text-[30px] font-extrabold uppercase sm:h-17.5 md:h-27.5 md:text-[60px] lg:h-45 lg:text-[80px]">
{/* Default */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden [clip-path:inset(0_0_20%_0)]"
ref={(el) => void (namesRef.current[0] = el)}
>
<h1
className="absolute w-full translate-y-1/2 text-center [font-size:inherit] text-black dark:text-white"
ref={(el) => void (headingsRef.current[0] = el)}
>
{fallbackText.split(" ").map((word, i) => (
<span key={i}>{word} </span>
))}
</h1>
</div>
{/* Members */}
{items.map((item, index) => (
<div
key={`${item.label}-${index}`}
className="absolute inset-0 flex items-center justify-center overflow-hidden [clip-path:inset(0_0_20%_0)]"
ref={(el) => void (namesRef.current[index + 1] = el)}
>
<h1
className="absolute w-full translate-y-[150%] text-center [font-size:inherit] text-[#ff3333]"
dangerouslySetInnerHTML={{
__html: item.label.replace(/\s/g, " "),
}}
ref={(el) => void (headingsRef.current[index + 1] = el)}
/>
</div>
))}
</div>
</section>
);
}Usage
Basic usage
import { TidalTextAnimation } from "@/components/ui/tidal-text-animation";
export default function DemoPage() {
return <TidalTextAnimation />;
}Custom items
import { TidalTextAnimation } from "@/components/ui/tidal-text-animation";
const items = [
{
label: "Crimson Horizon",
imageSrc: "/path/to/image.png",
},
{
label: "Neon Coast",
imageSrc: "/path/to/image.png",
},
{
label: "Golden Dunes",
imageSrc: "/path/to/image.png",
},
];
export default function DemoPage() {
return (
<TidalTextAnimation
items={items}
fallbackText="Studio Showcase"
className="max-w-5xl"
/>
);
}Props
Prop
Type
Masonry Grid
Masonry Grid is a responsive layout component that arranges items in a staggered, gap-free structure similar to a Pinterest-style layout. It adapts to varying content heights while maintaining a balanced and visually appealing grid, making it ideal for galleries, cards, and dynamic content collections.
Action Button
ActionButton is an animated CTA component with sliding text and icon transitions, designed for high-emphasis actions with multiple visual variants.