Motion Accordion
A clean Motion-powered FAQ accordion with spring-animated content reveal.
Installation
File Structure
motion-accordion.tsx
Usage
import { MotionAccordion } from "@/components/unlumen-ui/motion-accordion";
const items = [
{
question: "What is Unlumen UI?",
answer:
"A collection of premium components built with Motion and Tailwind CSS.",
},
{
question: "Do I need to install a package?",
answer:
"No — every component is delivered as source code via the registry.",
},
];
<MotionAccordion items={items} />;API Reference
MotionAccordion
itemsMotionAccordionItem[]—Array of FAQ items to render.
gap?number10Gap between accordion items in pixels.
className?string—Extra class names applied to the wrapper.
MotionAccordionItem
questionstring—The FAQ question shown in the header row.
answerstring—The answer text revealed when the item is open.
Notes
- Item backgrounds use
hsl(var(--foreground))so they invert correctly across themes. - Only one item can be open at a time. Clicking an open item collapses it.
- Content height is measured with
ResizeObserverso the spring animation always targets the exact pixel height, even for dynamic content. - Motion handles the icon rotation, panel height, and subtle vertical easing so the interaction stays snappy without extra visual effects.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
motion-accordion.tsx
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export interface MotionAccordionItem {
question: string;
answer: string;
}
export interface MotionAccordionProps {
items: MotionAccordionItem[];
/** @default 10 */
gap?: number;
className?: string;
}
function AccordionItem({
item,
isOpen,
onToggle,
itemId,
panelId,
}: {
item: MotionAccordionItem;
isOpen: boolean;
onToggle: () => void;
itemId: string;
panelId: string;
}) {
const contentRef = React.useRef<HTMLDivElement>(null);
const [contentH, setContentH] = React.useState(0);
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setContentH(el.scrollHeight));
ro.observe(el);
setContentH(el.scrollHeight);
return () => ro.disconnect();
}, []);
return (
<motion.div
layout
className={cn(
"overflow-hidden rounded-[30px] bg-foreground text-background shadow-[0_20px_45px_-30px_rgba(0,0,0,0.65)]",
isOpen &&
"border-foreground/25 shadow-[0_28px_60px_-34px_rgba(0,0,0,0.72)]",
)}
transition={{ type: "spring", stiffness: 280, damping: 28, mass: 0.9 }}
animate={{ scale: isOpen ? 1 : 0.985 }}
initial={false}
style={{ originX: 0.5, originY: 0 }}
>
<button
id={itemId}
type="button"
aria-controls={panelId}
aria-expanded={isOpen}
onClick={onToggle}
className="flex w-full cursor-pointer select-none items-center justify-between gap-4 px-7 py-5 text-left"
>
<span className="text-[clamp(1.2rem,1.6vw,1.3rem)] font-semibold leading-snug">
{item.question}
</span>
<motion.div
animate={{ rotate: isOpen ? 45 : 0 }}
transition={{ type: "spring", stiffness: 480, damping: 28 }}
className="flex-shrink-0 text-background/60"
>
<svg
width="13"
height="13"
viewBox="0 0 13 13"
fill="none"
aria-hidden
>
<path
d="M6.5 0v13M0 6.5h13"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
</motion.div>
</button>
<motion.div
id={panelId}
role="region"
aria-labelledby={itemId}
animate={{
height: isOpen ? contentH : 0,
opacity: isOpen ? 1 : 0,
}}
initial={false}
transition={{
height: { type: "spring", stiffness: 340, damping: 34, mass: 0.9 },
opacity: { duration: 0.2, ease: "easeOut" },
}}
style={{ overflow: "hidden" }}
>
<motion.div
ref={contentRef}
animate={{ y: isOpen ? 0 : -8 }}
transition={{
type: "spring",
stiffness: 360,
damping: 30,
mass: 0.8,
}}
className="px-7 pb-7"
>
<p className="text-[0.98rem] leading-8 text-background/65">
{item.answer}
</p>
</motion.div>
</motion.div>
</motion.div>
);
}
export function MotionAccordion({
items,
gap = 10,
className,
}: MotionAccordionProps) {
const rawId = React.useId();
const baseId = `accordion-${rawId.replace(/:/g, "")}`;
const [openIndex, setOpenIndex] = React.useState<number | null>(null);
const toggle = (i: number) => setOpenIndex((prev) => (prev === i ? null : i));
return (
<div className={cn("w-full", className)}>
<div className="flex flex-col rounded-[34px] p-3 " style={{ gap }}>
{items.map((item, i) => (
<AccordionItem
key={item.question}
item={item}
isOpen={openIndex === i}
onToggle={() => toggle(i)}
itemId={`${baseId}-trigger-${i}`}
panelId={`${baseId}-panel-${i}`}
/>
))}
</div>
</div>
);
}