Click on items to expand & collapse
Fast, accurate typing with real-time validation and helpful hints.
bouncy-accordion.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
CalendarDays,
ChevronDown,
CircleDot,
Inbox,
ShoppingCart,
TriangleAlert,
Wallet,
type LucideIcon,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
export interface AccordionItem {
icon: LucideIcon;
title: string;
body: string;
}
export const BOUNCY_ACCORDION_DEMO_ITEMS: AccordionItem[] = [
{
icon: Inbox,
title: "Type Shit",
body: "Fast, accurate typing with real-time validation and helpful hints.",
},
{
icon: CircleDot,
title: "Star Great",
body: "Spotlight the wins that matter with one-tap shoutouts.",
},
{
icon: CalendarDays,
title: "Schedule",
body: "Plan the week ahead with calm, flexible time blocks.",
},
{
icon: ShoppingCart,
title: "Buy Stuff",
body: "Checkout in seconds with saved carts and smart defaults.",
},
{
icon: TriangleAlert,
title: "Triangle Warning",
body: "Surface critical alerts with accessible, non-intrusive messaging.",
},
{
icon: Wallet,
title: "Account bal",
body: "Track balances across accounts with live, honest numbers.",
},
];
export function BouncyAccordion({
items = BOUNCY_ACCORDION_DEMO_ITEMS,
defaultOpen = 0,
previewIndex,
className,
}: {
items?: AccordionItem[];
defaultOpen?: number | null;
previewIndex?: number;
className?: string;
}) {
const reduce = useReducedMotion();
const locked = previewIndex !== undefined;
const [open, setOpen] = useState<number | null>(
locked ? (previewIndex ?? null) : (defaultOpen ?? null),
);
const current = locked ? (previewIndex ?? null) : open;
return (
<div className={cn("flex w-full max-w-[420px] flex-col gap-2", className)}>
{items.map((item, i) => {
const Icon = item.icon;
const expanded = current === i;
return (
<motion.div
key={item.title}
layout={reduce ? false : "position"}
whileTap={reduce ? undefined : { scale: 0.98 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 260, damping: 15, mass: 0.9 }
}
className={cn(
"overflow-hidden rounded-[20px] bg-white/[0.045]",
expanded && "bg-white/[0.06]",
)}
>
<button
type="button"
disabled={locked}
onClick={() => setOpen(expanded ? null : i)}
aria-expanded={expanded}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-white/[0.07] text-ink/60">
<Icon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 truncate text-[15px] font-medium text-ink">
{item.title}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: expanded ? 180 : 0 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 350, damping: 14 }
}
className="grid shrink-0 place-items-center text-ink/50"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</button>
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={
reduce
? { duration: 0 }
: {
type: "spring",
stiffness: 240,
damping: 14,
mass: 1,
}
}
className="origin-top overflow-hidden"
>
<motion.p
initial={reduce ? false : { y: -6 }}
animate={{ y: 0 }}
exit={reduce ? undefined : { y: -6 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 260, damping: 13 }
}
className="px-4 pb-4 pl-[52px] text-[13.5px] leading-relaxed text-ink/55"
>
{item.body}
</motion.p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}