Bouncy Accordion

Elastic easing and smooth height animation for a playful reveal.

Mainstream Ready
01

Bouncy Accordion

A standard accordion enhanced with GSAP elastic easing for a "bouncy" feel when opening.

Installation

npx shadcn@latest add https://aftershade.pages.dev/registry/bouncy-accordion.json

Source Code

"use client";

import React, { useRef, useEffect, useState } from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import gsap from "gsap";

export function BouncyAccordion({
    items,
    className
}: {
    items: { title: string; content: string; id: string }[];
    className?: string;
}) {
    return (
        <AccordionPrimitive.Root
            type="single"
            collapsible
            className={cn("w-full space-y-4", className)}
        >
            {items.map((item) => (
                <BouncyAccordionItem key={item.id} value={item.id} title={item.title}>
                    {item.content}
                </BouncyAccordionItem>
            ))}
        </AccordionPrimitive.Root>
    );
}

function BouncyAccordionItem({
    value,
    title,
    children,
    className
}: {
    value: string;
    title: string;
    children: React.ReactNode;
    className?: string;
}) {
    const [isOpen, setIsOpen] = useState(false);
    const contentRef = useRef<HTMLDivElement>(null);
    const innerRef = useRef<HTMLDivElement>(null);
    const chevronRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (!contentRef.current || !innerRef.current) return;

        if (isOpen) {
            // Open animation with bounce
            gsap.to(contentRef.current, {
                height: "auto",
                duration: 0.8,
                ease: "elastic.out(1, 0.6)",
                onStart: () => {
                    gsap.set(contentRef.current, { overflow: "visible" });
                }
            });
            gsap.to(innerRef.current, {
                opacity: 1,
                y: 0,
                scale: 1,
                duration: 0.5,
                delay: 0.1,
                ease: "power2.out"
            });
            gsap.to(chevronRef.current, {
                rotate: 180,
                duration: 0.4,
                ease: "power2.out"
            });
        } else {
            // Close animation
            gsap.to(contentRef.current, {
                height: 0,
                duration: 0.4,
                ease: "power2.inOut",
                onStart: () => {
                    gsap.set(contentRef.current, { overflow: "hidden" });
                }
            });
            gsap.to(innerRef.current, {
                opacity: 0,
                y: -10,
                scale: 0.98,
                duration: 0.3,
                ease: "power2.in"
            });
            gsap.to(chevronRef.current, {
                rotate: 0,
                duration: 0.4,
                ease: "power2.out"
            });
        }
    }, [isOpen]);

    return (
        <AccordionPrimitive.Item
            value={value}
            className={cn(
                "group overflow-hidden rounded-3xl border border-white/5 bg-secondary-1/5 transition-colors hover:bg-secondary-1/10",
                isOpen && "bg-secondary-1/10 border-white/10",
                className
            )}
        >
            <AccordionPrimitive.Header className="flex">
                <AccordionPrimitive.Trigger
                    onClick={() => setIsOpen(!isOpen)}
                    className="flex flex-1 items-center justify-between p-6 text-left text-lg font-semibold transition-all outline-none"
                >
                    <span className="bg-gradient-to-r from-white to-white/60 bg-clip-text text-transparent">
                        {title}
                    </span>
                    <div ref={chevronRef} className="text-primary-1">
                        <ChevronDown className="h-6 w-6" />
                    </div>
                </AccordionPrimitive.Trigger>
            </AccordionPrimitive.Header>

            <AccordionPrimitive.Content
                className="overflow-hidden"
                style={{ height: 0 }}
                ref={contentRef}
            >
                <div ref={innerRef} className="p-6 pt-0 text-muted-foreground leading-relaxed opacity-0">
                    {children}
                </div>
            </AccordionPrimitive.Content>
        </AccordionPrimitive.Item>
    );
}