Swap

PreviousNext

A flexible swap component that animates content changes with smooth customizable transition effects

Docs
paceuiui

Preview

Loading preview…
gsap/swap.tsx
import { ComponentProps, ReactNode, useMemo, useRef, useState } from "react";

import { useGSAP } from "@gsap/react";
import gsap from "gsap";

const effectPresets = {
    slideUp: [
        {
            y: "-100%",
        },
        {
            y: "100%",
        },
        {
            y: 0,
        },
    ],
    slideDown: [
        {
            y: "100%",
        },
        {
            y: "-100%",
        },
        {
            y: 0,
        },
    ],
    opacity: [
        {
            opacity: 0,
        },
        {
            opacity: 0,
        },
        {
            opacity: 1,
        },
    ],
    blur: [
        {
            filter: "blur(12px)",
        },
        {
            filter: "blur(12px)",
        },
        {
            filter: "blur(0px)",
        },
    ],
    grayscale: [
        {
            filter: "grayscale(100%)",
        },
        {
            filter: "grayscale(100%)",
        },
        {
            filter: "grayscale(0%)",
        },
    ],
} as const;

export type SwapProps<T = object> = Omit<ComponentProps<"div">, "children"> & {
    state: T;
    children: (state: T) => ReactNode;
    effects?: (keyof typeof effectPresets)[];
    duration?: number;
};

const mergeVars = (varsArray: Record<string, string | number>[]) => {
    const merged: Record<string, string | number> = {};

    for (const vars of varsArray) {
        for (const [key, value] of Object.entries(vars)) {
            if (key === "filter" && merged.filter) {
                merged.filter += ` ${value}`; // combine filters
            } else {
                merged[key] = value; // last one wins for other props
            }
        }
    }

    return merged;
};

export const Swap = <T,>({ state, children, duration = 0.4, effects = [], ...props }: SwapProps<T>) => {
    const [currentState, setCurrentState] = useState(state);

    const containerRef = useRef<HTMLDivElement>(null);
    const contentRef = useRef<HTMLDivElement>(null);

    const [enterVars, exitVars, finalVars] = useMemo(
        () => Array.from({ length: 3 }, (_, i) => mergeVars(effects.map((e) => effectPresets[e][i]))),
        [effects],
    );

    useGSAP(
        () => {
            if (currentState === state) return;
            const content = contentRef.current;
            if (!content) return;

            gsap.to(content, {
                ...enterVars,
                duration,
                ease: "power2.in",
                onComplete: () => {
                    setCurrentState(state);
                    gsap.fromTo(content, exitVars, {
                        ...finalVars,
                        duration,
                        ease: "power2.out",
                    });
                },
            });
        },
        { dependencies: [state] },
    );

    useGSAP(
        () => {
            if (!containerRef.current || !contentRef.current) return;

            const box = contentRef.current.getBoundingClientRect();

            gsap.to(containerRef.current, {
                width: box.width,
                height: box.height,
                duration: 0.4,
                ease: "power2.out",
            });
        },
        { dependencies: [currentState] },
    );

    return (
        <div {...props} ref={containerRef}>
            <div ref={contentRef} className="inline-block">
                {children(currentState)}
            </div>
        </div>
    );
};

Installation

npx shadcn@latest add @paceui/swap

Usage

import { Swap } from "@/components/ui/swap"
<Swap />