Fluid Accordion
A spring-animated accordion with fluid height transitions, icon morphing, and glassmorphic panels. Panels expand and collapse with organic easing powered by Framer Motion layout springs.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add fluid-accordionSource Code
'use client'
import { useState } from 'react'
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion'
import { ChevronDown, Zap, Shield, Globe, Cpu } from 'lucide-react'
type AccordionItemData = {
title: string
content: string
icon: React.ComponentType<{ size?: number; strokeWidth?: number }>
}
const DEFAULT_ITEMS: AccordionItemData[] = [
{
icon: Zap,
title: 'Instant Performance',
content:
"Powered by Framer Motion spring physics, panels expand and collapse with organic easing. Height, opacity, and icon scale animate together for a single fluid motion.",
},
{
icon: Shield,
title: 'Accessible by Default',
content:
'Semantic button triggers, keyboard focus rings, and ARIA-friendly structure ensure the accordion works for screen readers and keyboard-only users.',
},
{
icon: Globe,
title: 'Global Theming',
content:
'Neutral glass surfaces adapt to light and dark mode. Swap Tailwind tokens or pass custom item data to match your design system.',
},
{
icon: Cpu,
title: 'Hardware Accelerated',
content:
'GPU-friendly transforms keep animations at 60fps even when multiple panels animate in sequence on lower-end devices.',
},
]
type FluidAccordionProps = {
items?: AccordionItemData[]
defaultOpen?: number | null
}
function AccordionItem({
title,
content,
icon: Icon,
isOpen,
onClick,
}: {
title: string
content: string
icon: React.ComponentType<{ size?: number; strokeWidth?: number }>
isOpen: boolean
onClick: () => void
}) {
return (
<motion.div
layout
initial={false}
className={`group overflow-hidden rounded-2xl border relative ${
isOpen
? 'bg-neutral-900/[0.04] dark:bg-white/[0.06] border-neutral-300/80 dark:border-white/15 shadow-lg shadow-neutral-900/5'
: 'bg-white/80 dark:bg-neutral-900/80 border-neutral-200/80 dark:border-white/8 hover:border-neutral-300 dark:hover:border-white/15'
} backdrop-blur-md`}
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
>
<motion.button
layout
onClick={onClick}
className="flex w-full items-center justify-between p-5 text-left relative z-10"
whileTap={{ scale: 0.995 }}
>
<div className="flex items-center gap-4">
<motion.div
layout
animate={{
scale: isOpen ? 1.08 : 1,
rotate: isOpen ? 4 : 0,
}}
transition={{ type: 'spring', stiffness: 500, damping: 28 }}
className={`p-3 rounded-xl ${
isOpen
? 'bg-neutral-900 dark:bg-white text-white dark:text-black shadow-md'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400'
}`}
>
<Icon size={20} strokeWidth={1.5} />
</motion.div>
<motion.span
layout
className={`block text-base font-medium tracking-tight ${
isOpen ? 'text-neutral-900 dark:text-white' : 'text-neutral-600 dark:text-neutral-400'
}`}
>
{title}
</motion.span>
</div>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 24 }}
className={`w-8 h-8 flex items-center justify-center rounded-full border ${
isOpen
? 'border-neutral-900/20 dark:border-white/30 text-neutral-900 dark:text-white'
: 'border-neutral-200 dark:border-white/10 text-neutral-400'
}`}
>
<ChevronDown size={16} />
</motion.div>
</motion.button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 320, damping: 30, mass: 0.8 }}
className="overflow-hidden"
>
<motion.div
initial={{ y: -8 }}
animate={{ y: 0 }}
exit={{ y: -4 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="px-5 pb-6 pl-[4.5rem] pr-8 text-neutral-500 dark:text-neutral-400 leading-relaxed text-sm"
>
{content}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
export const FluidAccordion = ({ items = DEFAULT_ITEMS, defaultOpen = 0 }: FluidAccordionProps) => {
const [openIndex, setOpenIndex] = useState<number | null>(defaultOpen)
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-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:24px_24px]" />
<div className="w-full max-w-2xl relative z-10">
<div className="mb-8 pl-1">
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white tracking-tight">
Product Overview
</h2>
<p className="text-neutral-500 dark:text-neutral-400 mt-1.5 text-sm">
Tap a section — watch the panel flow open with spring-based height animation.
</p>
</div>
<LayoutGroup>
<div className="space-y-3">
{items.map((item, index) => (
<AccordionItem
key={item.title}
title={item.title}
content={item.content}
icon={item.icon}
isOpen={openIndex === index}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
/>
))}
</div>
</LayoutGroup>
</div>
</div>
)
}
export default FluidAccordion
Dependencies
framer-motion: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| items | AccordionItemData[] | - | Array of accordion sections with title, content, and icon. |
| defaultOpen | number | null | - | Index of the panel open on first render. Pass null for all closed. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.