Animated Stack

Next

A smooth stacked card component that expands on hover and collapses with animation

Docs
paceuiui

Preview

Loading preview…
gsap/animated-stack.tsx
"use client";

import { Children, ReactNode, useCallback, useEffect, useRef, useState } from "react";

import { gsap } from "gsap";

type AnimatedStackProps = {
    children: ReactNode;
    visibleCount?: number;
    gap?: number;
    offset?: number;
    direction?: "up" | "down";
};

export const AnimatedStack = ({
    children,
    visibleCount = 3,
    gap = 8,
    offset = 8,
    direction = "up",
}: AnimatedStackProps) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const itemsRef = useRef<HTMLDivElement[]>([]);
    const prevItemCount = useRef(0);
    const timeoutRef = useRef<NodeJS.Timeout>(null);
    const lastItemHeight = useRef(0);
    const [expanded, setExpanded] = useState(false);

    itemsRef.current = [];

    const registerItemRef = (el: HTMLDivElement) => {
        if (el && !itemsRef.current.includes(el)) itemsRef.current.push(el);
    };

    const calculatePositions = useCallback(
        (arr: number[]) =>
            arr
                .reduce((acc, val) => [...acc, acc.at(-1)! + val + gap], [0])
                .slice(0, -1)
                .reverse(),
        [gap],
    );

    useEffect(() => {
        const items = itemsRef.current;
        const total = items.length;

        const heights = items.map((el) => el.getBoundingClientRect().height);
        lastItemHeight.current = heights.at(-1) || 0;
        const positions = calculatePositions(heights);

        items.forEach((el, i) => {
            const rev = total - 1 - i;
            let y = 0;
            let opacity = 1,
                scale = 1;

            if (expanded) {
                y = (positions[i] || 0) * (direction == "down" ? 1 : -1);
            } else {
                if (rev >= visibleCount) {
                    y = offset * (visibleCount - 1);
                    opacity = 0;
                } else if (i !== total - 1) {
                    y = rev * offset * (direction == "down" ? 1 : -1);
                    scale = 1 - (total - i) * 0.015;
                }
            }

            const isNew = total > prevItemCount.current && i === total - 1;

            if (isNew) {
                gsap.fromTo(
                    el,
                    { opacity: 0, y: y + 20 * (direction == "down" ? -1 : 1) },
                    { opacity: 1, y, duration: 0.8, ease: "power2.out" },
                );
            } else {
                gsap.to(el, { y, opacity, scaleX: scale, duration: 0.8, ease: "power4.inOut" });
            }
        });
        prevItemCount.current = total;
    }, [children, expanded, visibleCount, direction, calculatePositions, gap, offset]);

    return (
        <div
            ref={containerRef}
            className="relative w-full overflow-visible"
            style={{ height: lastItemHeight.current }}
            onMouseEnter={() => {
                if (timeoutRef.current) clearTimeout(timeoutRef.current);
                setExpanded(true);
            }}
            onMouseLeave={() => {
                timeoutRef.current = setTimeout(() => setExpanded(false), 1500);
            }}>
            {Children.map(children, (child) => (
                <div ref={registerItemRef} className="absolute w-full">
                    {child}
                </div>
            ))}
        </div>
    );
};

Installation

npx shadcn@latest add @paceui/animated-stack

Usage

import { AnimatedStack } from "@/components/ui/animated-stack"
<AnimatedStack />