Animated Tabs

Tabs with physics-aware transitions and sticky indicators.

01

Flip Tabs

Project Overview Content

Source Code

"use client";

import React, { useRef, useEffect, useState } from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
import gsap from "gsap";

export function FlipTabs({
    tabs,
    defaultValue,
    className
}: {
    tabs: { label: string; value: string; content: React.ReactNode }[];
    defaultValue?: string;
    className?: string;
}) {
    const [activeTab, setActiveTab] = useState(defaultValue || tabs[0].value);
    const containerRef = useRef<HTMLDivElement>(null);

    return (
        <TabsPrimitive.Root
            value={activeTab}
            onValueChange={setActiveTab}
            className={cn("w-full space-y-8", className)}
        >
            <TabsPrimitive.List className="flex items-center gap-2 p-1.5 bg-zinc-950 rounded-2xl border border-white/5 w-fit">
                {tabs.map((tab) => (
                    <TabsPrimitive.Trigger
                        key={tab.value}
                        value={tab.value}
                        className={cn(
                            "px-6 py-2.5 text-sm font-bold transition-all duration-300 outline-none rounded-xl relative",
                            activeTab === tab.value
                                ? "bg-zinc-800 text-white shadow-xl translate-y-[-2px]"
                                : "text-zinc-500 hover:text-zinc-300"
                        )}
                    >
                        {tab.label}
                    </TabsPrimitive.Trigger>
                ))}
            </TabsPrimitive.List>

            <div className="perspective-[1000px]">
                {tabs.map((tab) => (
                    <TabsPrimitive.Content
                        key={tab.value}
                        value={tab.value}
                        className="outline-none data-[state=inactive]:hidden"
                    >
                        <FlipContent>{tab.content}</FlipContent>
                    </TabsPrimitive.Content>
                ))}
            </div>
        </TabsPrimitive.Root>
    );
}

function FlipContent({ children }: { children: React.ReactNode }) {
    const contentRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (contentRef.current) {
            gsap.fromTo(contentRef.current,
                { rotateX: -15, opacity: 0, scale: 0.95, y: 20 },
                { rotateX: 0, opacity: 1, scale: 1, y: 0, duration: 0.6, ease: "back.out(1.7)" }
            );
        }
    }, []);

    return (
        <div
            ref={contentRef}
            className="bg-zinc-900 border border-white/5 rounded-[2.5rem] p-10 min-h-[250px] shadow-2xl origin-top"
        >
            {children}
        </div>
    );
}
02

Fluid Indicator

Project Overview Content

Source Code

"use client";

import React, { useRef, useEffect, useState } from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
import gsap from "gsap";

export function FluidTabs({
    tabs,
    defaultValue,
    className
}: {
    tabs: { label: string; value: string; content: React.ReactNode }[];
    defaultValue?: string;
    className?: string;
}) {
    const [activeTab, setActiveTab] = useState(defaultValue || tabs[0].value);
    const triggerRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({});
    const indicatorRef = useRef<HTMLDivElement>(null);
    const containerRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const activeTrigger = triggerRefs.current[activeTab];
        if (activeTrigger && indicatorRef.current) {
            const { offsetLeft, offsetWidth } = activeTrigger;

            gsap.to(indicatorRef.current, {
                x: offsetLeft,
                width: offsetWidth,
                duration: 0.5,
                ease: "elastic.out(1, 0.8)",
            });
        }
    }, [activeTab]);

    return (
        <TabsPrimitive.Root
            value={activeTab}
            onValueChange={setActiveTab}
            className={cn("w-full space-y-8", className)}
        >
            <div className="relative inline-flex p-1 bg-secondary-1/5 rounded-2xl border border-white/5" ref={containerRef}>
                <div
                    ref={indicatorRef}
                    className="absolute h-[calc(100%-8px)] top-1 left-0 bg-primary-1 rounded-xl shadow-[0_0_20px_rgba(var(--primary-1-rgb),0.3)] z-0"
                />
                <TabsPrimitive.List className="relative z-10 flex items-center">
                    {tabs.map((tab) => (
                        <TabsPrimitive.Trigger
                            key={tab.value}
                            value={tab.value}
                            ref={(el) => { triggerRefs.current[tab.value] = el; }}
                            className={cn(
                                "px-6 py-2.5 text-sm font-semibold transition-colors duration-300 outline-none rounded-xl",
                                activeTab === tab.value ? "text-white" : "text-muted-foreground hover:text-white"
                            )}
                        >
                            {tab.label}
                        </TabsPrimitive.Trigger>
                    ))}
                </TabsPrimitive.List>
            </div>

            <div className="relative overflow-hidden min-h-[200px] rounded-3xl bg-secondary-1/5 border border-white/5 p-8">
                {tabs.map((tab) => (
                    <TabsPrimitive.Content
                        key={tab.value}
                        value={tab.value}
                        className="outline-none data-[state=inactive]:hidden"
                    >
                        <FluidContent>{tab.content}</FluidContent>
                    </TabsPrimitive.Content>
                ))}
            </div>
        </TabsPrimitive.Root>
    );
}

function FluidContent({ children }: { children: React.ReactNode }) {
    const contentRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (contentRef.current) {
            gsap.fromTo(contentRef.current,
                { opacity: 0, y: 10, filter: "blur(4px)" },
                { opacity: 1, y: 0, filter: "blur(0px)", duration: 0.4, ease: "power2.out" }
            );
        }
    }, []);

    return <div ref={contentRef}>{children}</div>;
}
03

Gooey Tabs

Project Overview Content

Source Code

"use client";

import React, { useRef, useEffect, useState } from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
import gsap from "gsap";

export function GooeyTabs({
    tabs,
    defaultValue,
    className
}: {
    tabs: { label: string; value: string; content: React.ReactNode }[];
    defaultValue?: string;
    className?: string;
}) {
    const [activeTab, setActiveTab] = useState(defaultValue || tabs[0].value);
    const containerRef = useRef<HTMLDivElement>(null);

    return (
        <TabsPrimitive.Root
            value={activeTab}
            onValueChange={setActiveTab}
            className={cn("w-full space-y-12", className)}
        >
            <TabsPrimitive.List className="flex items-center justify-center gap-12 border-b border-white/5 pb-4">
                {tabs.map((tab) => (
                    <TabsPrimitive.Trigger
                        key={tab.value}
                        value={tab.value}
                        className={cn(
                            "text-lg font-black uppercase tracking-tighter transition-all duration-500 outline-none relative group",
                            activeTab === tab.value
                                ? "text-primary-1 scale-110"
                                : "text-zinc-600 hover:text-zinc-400"
                        )}
                    >
                        {tab.label}
                        {activeTab === tab.value && (
                            <div
                                className="absolute -bottom-4 left-0 w-full h-1 bg-primary-1 rounded-full"
                                id="active-underline"
                            />
                        )}
                    </TabsPrimitive.Trigger>
                ))}
            </TabsPrimitive.List>

            <div className="relative">
                {tabs.map((tab) => (
                    <TabsPrimitive.Content
                        key={tab.value}
                        value={tab.value}
                        className="outline-none data-[state=inactive]:hidden"
                    >
                        <GooeyContent>{tab.content}</GooeyContent>
                    </TabsPrimitive.Content>
                ))}
            </div>
        </TabsPrimitive.Root>
    );
}

function GooeyContent({ children }: { children: React.ReactNode }) {
    const contentRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (contentRef.current) {
            const children = contentRef.current.children;
            gsap.fromTo(contentRef.current,
                { x: 15, opacity: 0, scale: 0.99 },
                { x: 0, opacity: 1, scale: 1, duration: 0.4, ease: "power4.out" }
            );

            if (children.length > 0) {
                gsap.fromTo(Array.from(children),
                    { y: 10, opacity: 0 },
                    { y: 0, opacity: 1, duration: 0.3, stagger: 0.03, ease: "back.out(2)" }
                );
            }
        }
    }, []);

    return (
        <div
            ref={contentRef}
            className="min-h-[300px] flex flex-col items-center justify-center text-center space-y-6"
        >
            {children}
        </div>
    );
}