Velocity UI
Loading…
Menu

Fluid Accordion

An expandable list with smooth, physics-based height animations.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add fluid-accordion

Source Code

fluid-accordion.tsx
'use client';

import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Minus, ChevronDown, Zap, Shield, Globe, Cpu, ArrowUpRight } from 'lucide-react';

interface AccordionItemProps {
    title: string;
    content: string;
    icon: React.ElementType;
    isOpen: boolean;
    onClick: () => void;
    index: number;
}

const AccordionItem = ({ title, content, icon: Icon, isOpen, onClick, index }: AccordionItemProps) => {
    return (
        <motion.div
            initial={false}
            className={`group mb-4 overflow-hidden rounded-xl border relative ${
                isOpen 
                    ? 'bg-neutral-900/5 dark:bg-white/5 border-neutral-200 dark:border-white/10' 
                    : 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-white/5 hover:border-neutral-300 dark:hover:border-white/10'
            } backdrop-blur-md transition-all duration-500`}
        >
            {/* Shimmer Effect */}
            <div className="absolute inset-0 -translate-x-full group-hover:animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/5 to-transparent pointer-events-none" />

            <motion.button
                initial={false}
                onClick={onClick}
                className="flex w-full items-center justify-between p-6 text-left relative z-10"
            >
                <div className="flex items-center gap-5">
                    <div className={`relative p-3 rounded-xl transition-all duration-500 ${
                        isOpen 
                            ? 'bg-neutral-900 dark:bg-white text-white dark:text-black shadow-lg scale-110' 
                            : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 group-hover:scale-105'
                    }`}>
                        <Icon size={22} strokeWidth={1.5} />
                    </div>
                    <div>
                        <span className={`block text-lg font-medium tracking-tight transition-colors duration-300 ${
                            isOpen ? 'text-neutral-900 dark:text-white' : 'text-neutral-600 dark:text-neutral-400'
                        }`}>
                            {title}
                        </span>
                    </div>
                </div>
                
                <div className={`relative w-8 h-8 flex items-center justify-center rounded-full border transition-all duration-500 ${
                    isOpen 
                        ? 'border-neutral-900 dark:border-white text-neutral-900 dark:text-white rotate-180' 
                        : 'border-neutral-200 dark:border-white/10 text-neutral-400 group-hover:border-neutral-300 dark:group-hover:border-white/20'
                }`}>
                    <ChevronDown size={16} />
                </div>
            </motion.button>
            <AnimatePresence initial={false}>
                {isOpen && (
                    <motion.section
                        key="content"
                        initial="collapsed"
                        animate="open"
                        exit="collapsed"
                        variants={{
                            open: { opacity: 1, height: "auto" },
                            collapsed: { opacity: 0, height: 0 }
                        }}
                        transition={{ duration: 0.4, ease: [0.04, 0.62, 0.23, 0.98] }}
                    >
                        <motion.div
                            variants={{ collapsed: { y: -10, opacity: 0 }, open: { y: 0, opacity: 1 } }}
                            transition={{ duration: 0.3, delay: 0.1 }}
                            className="px-6 pb-8 pl-[5.5rem] pr-12 text-neutral-500 dark:text-neutral-400 leading-relaxed text-[15px]"
                        >
                            <p>{content}</p>
                        </motion.div>
                    </motion.section>
                )}
            </AnimatePresence>
        </motion.div>
    );
};

export const FluidAccordion = () => {
    const [openIndex, setOpenIndex] = useState<number | null>(0);

    const items = [
        {
            icon: Zap,
            title: "Instant Performance",
            content: "Powered by Framer Motion's layout animations, every interaction feels immediate and fluid. No jank, no layout shifts, just pure 60fps buttery smoothness."
        },
        {
            icon: Shield,
            title: "Accessible by Default",
            content: "Built with semantic HTML and proper ARIA attributes. Keyboard navigation works out of the box, ensuring your UI is usable by everyone."
        },
        {
            icon: Globe,
            title: "Global Theming",
            content: "Seamlessly adapts to your application's design system. Whether you're using Tailwind, CSS modules, or styled-components, customization is a breeze."
        },
        {
            icon: Cpu,
            title: "Hardware Accelerated",
            content: "Animations are offloaded to the GPU where possible, keeping your main thread free for complex logic and ensuring battery-friendly performance."
        }
    ];

    return (
        <div className="w-full min-h-[600px] flex items-center justify-center bg-neutral-50 dark:bg-neutral-950 p-8 transition-colors duration-500 relative">
             <div className="absolute inset-0 bg-grid-black/[0.02] dark:bg-grid-white/[0.02] bg-[size:20px_20px]" />
            <div className="w-full max-w-2xl relative z-10">
                <div className="mb-10 pl-2">
                    <h2 className="text-3xl font-light text-neutral-900 dark:text-white tracking-tight">Velocity <span className="font-bold">Features</span></h2>
                    <p className="text-neutral-500 dark:text-neutral-400 mt-2 text-lg font-light">Discover what makes it unique</p>
                </div>
                <div className="space-y-3">
                    {items.map((item, index) => (
                        <AccordionItem
                            key={index}
                            index={index}
                            title={item.title}
                            content={item.content}
                            icon={item.icon}
                            isOpen={openIndex === index}
                            onClick={() => setOpenIndex(openIndex === index ? null : index)}
                        />
                    ))}
                </div>
            </div>
        </div>
    );
};

Dependencies

  • framer-motion: latest
  • lucide-react: latest