Animated Wave Footer
21st.dev animated-footer: requestAnimationFrame sine wave bars, split link rows, amber accent on pure black.
Dependencies
react
React only — wave uses requestAnimationFrame"use client";
import React, { useEffect, useRef, useState } from "react";
const leftLinks = [
{ href: "/ui", label: "UI Library" },
{ href: "/tools", label: "Free Tools" },
{ href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" },
];
const rightLinks = [
{ href: "/privacy", label: "Privacy" },
{ href: "/terms", label: "Terms" },
{ href: "/support", label: "Support" },
{ href: "#top", label: "Back to top" },
];
export function AnimatedWaveFooter() {
const waveRefs = useRef<(HTMLDivElement | null)[]>([]);
const footerRef = useRef<HTMLElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
const frameRef = useRef<number | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), { threshold: 0.15 });
if (footerRef.current) observer.observe(footerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
let t = 0;
const animate = () => {
waveRefs.current.forEach((el, index) => {
if (el) {
const offset = Math.max(0, 20 * Math.sin((t + index) * 0.3));
el.style.transform = `translateY(${index + offset}px)`;
}
});
t += 0.1;
frameRef.current = requestAnimationFrame(animate);
};
if (isVisible) frameRef.current = requestAnimationFrame(animate);
else if (frameRef.current) cancelAnimationFrame(frameRef.current);
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); };
}, [isVisible]);
return (
<footer ref={footerRef} className="relative flex w-full flex-col justify-between bg-black text-white">
<div className="container mx-auto flex w-full flex-col justify-between gap-8 px-6 pb-24 pt-12 md:flex-row">
<div>
<p className="text-lg font-semibold text-amber-400">TetraKits</p>
<ul className="mt-4 flex flex-wrap gap-x-6 gap-y-2">
{leftLinks.map((link) => (
<li key={link.label}><a href={link.href} className="text-sm text-zinc-400 transition hover:text-amber-400">{link.label}</a></li>
))}
</ul>
<p className="mt-6 flex items-center gap-2 text-sm text-zinc-500">
<svg viewBox="0 0 71 25" className="h-4 w-auto fill-current opacity-50" aria-hidden><circle cx="12.5" cy="12.5" r="12.5" /></svg>
© 2026 TetraKits · hello@tetrakits.com
</p>
</div>
<div className="md:text-right">
<ul className="flex flex-wrap gap-x-6 gap-y-2 md:justify-end">
{rightLinks.map((link) => (
<li key={link.label}><a href={link.href} className="text-sm text-zinc-400 transition hover:text-amber-400">{link.label}</a></li>
))}
</ul>
</div>
</div>
<div aria-hidden className="overflow-hidden" style={{ height: 180 }}>
{Array.from({ length: 23 }).map((_, index) => (
<div
key={index}
ref={(el) => { waveRefs.current[index] = el; }}
style={{ height: index + 1, backgroundColor: "#FBBF24", marginTop: -2, willChange: "transform" }}
/>
))}
</div>
</footer>
);
}
Please implement this code in the current project and add it to (Your Desired Section, e.g. homepage hero, pricing page, dashboard). Change the primary accent color to (YOUR HEX CODE COLOR, e.g. #6366F1).
Requirements:
- Use React + Tailwind CSS (match the project's existing setup)
- Install dependencies if needed: React only — wave uses requestAnimationFrame
- Keep the layout responsive on mobile and desktop
- Replace placeholder copy with content that fits the project
The UI code:
"use client";
import React, { useEffect, useRef, useState } from "react";
const leftLinks = [
{ href: "/ui", label: "UI Library" },
{ href: "/tools", label: "Free Tools" },
{ href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" },
];
const rightLinks = [
{ href: "/privacy", label: "Privacy" },
{ href: "/terms", label: "Terms" },
{ href: "/support", label: "Support" },
{ href: "#top", label: "Back to top" },
];
export function AnimatedWaveFooter() {
const waveRefs = useRef<(HTMLDivElement | null)[]>([]);
const footerRef = useRef<HTMLElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
const frameRef = useRef<number | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), { threshold: 0.15 });
if (footerRef.current) observer.observe(footerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
let t = 0;
const animate = () => {
waveRefs.current.forEach((el, index) => {
if (el) {
const offset = Math.max(0, 20 * Math.sin((t + index) * 0.3));
el.style.transform = `translateY(${index + offset}px)`;
}
});
t += 0.1;
frameRef.current = requestAnimationFrame(animate);
};
if (isVisible) frameRef.current = requestAnimationFrame(animate);
else if (frameRef.current) cancelAnimationFrame(frameRef.current);
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); };
}, [isVisible]);
return (
<footer ref={footerRef} className="relative flex w-full flex-col justify-between bg-black text-white">
<div className="container mx-auto flex w-full flex-col justify-between gap-8 px-6 pb-24 pt-12 md:flex-row">
<div>
<p className="text-lg font-semibold text-amber-400">TetraKits</p>
<ul className="mt-4 flex flex-wrap gap-x-6 gap-y-2">
{leftLinks.map((link) => (
<li key={link.label}><a href={link.href} className="text-sm text-zinc-400 transition hover:text-amber-400">{link.label}</a></li>
))}
</ul>
<p className="mt-6 flex items-center gap-2 text-sm text-zinc-500">
<svg viewBox="0 0 71 25" className="h-4 w-auto fill-current opacity-50" aria-hidden><circle cx="12.5" cy="12.5" r="12.5" /></svg>
© 2026 TetraKits · hello@tetrakits.com
</p>
</div>
<div className="md:text-right">
<ul className="flex flex-wrap gap-x-6 gap-y-2 md:justify-end">
{rightLinks.map((link) => (
<li key={link.label}><a href={link.href} className="text-sm text-zinc-400 transition hover:text-amber-400">{link.label}</a></li>
))}
</ul>
</div>
</div>
<div aria-hidden className="overflow-hidden" style={{ height: 180 }}>
{Array.from({ length: 23 }).map((_, index) => (
<div
key={index}
ref={(el) => { waveRefs.current[index] = el; }}
style={{ height: index + 1, backgroundColor: "#FBBF24", marginTop: -2, willChange: "transform" }}
/>
))}
</div>
</footer>
);
}
Replace (Your Desired Section) and (YOUR HEX CODE COLOR) before pasting into Cursor, Copilot, or ChatGPT.