TetraKits UI Animated Wave Footer ← Library

Animated Wave Footer

21st.dev animated-footer: requestAnimationFrame sine wave bars, split link rows, amber accent on pure black.

animated wave footer21st dev animatedamber footer reactwave animation footerblack premium footer
Live preview

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>
  );
}