Bouncy Accordion
Single-open accordion with weighted spring layout, icon rows and reduced-motion-safe content reveals.
Preview
Plan announcements, staging checks, reminders, and quiet periods around the same timeline.
TSXcomponents/previews/motion/bouncy-accordion.preview.tsx
"use client";
import {
CalendarClock,
FileText,
FolderKanban,
PackageCheck,
RadioTower,
ShieldCheck,
} from "lucide-react";
import { BouncyAccordion } from "@/components/motion/bouncy-accordion";
const items = [
{
id: "brief",
title: "Release Brief",
description:
"Collect launch notes, owners, and risks in one compact handoff before the release window opens.",
icon: <FileText className="h-4 w-4" />,
},
{
id: "launch",
title: "Launch Checklist",
description:
"Verify copy, links, analytics, rollback steps, and final approvals without leaving the queue.",
icon: <ShieldCheck className="h-4 w-4" />,
},
{
id: "campaign",
title: "Campaign Notes",
description:
"Keep channel-specific notes close to the task while preserving a calm collapsed list.",
icon: <RadioTower className="h-4 w-4" />,
},
{
id: "calendar",
title: "Rollout Calendar",
description:
"Plan announcements, staging checks, reminders, and quiet periods around the same timeline.",
icon: <CalendarClock className="h-4 w-4" />,
},
{
id: "ship",
title: "Ship Build",
description:
"Track the current artifact, deploy status, and final sign-off before marking the release complete.",
icon: <PackageCheck className="h-4 w-4" />,
},
{
id: "archive",
title: "Archive Assets",
description:
"Move final copy, images, and source files into the campaign folder once the rollout is done.",
icon: <FolderKanban className="h-4 w-4" />,
},
];
export function BouncyAccordionPreview() {
return (
<div className="flex min-h-96 w-full items-center justify-center">
<div className="w-full max-w-sm h-[480px]">
<BouncyAccordion items={items} defaultValue="calendar" />
</div>
</div>
);
}
TSXcomponents/motion/bouncy-accordion.tsx
"use client";
// beui.dev/components/motion/bouncy-accordion
import {
motion,
useReducedMotion,
type Transition,
} from "motion/react";
import { ChevronDown } from "lucide-react";
import {
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BouncyAccordionItem = {
id: string;
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
};
export type BouncyAccordionClassNames = {
root?: string;
item?: string;
trigger?: string;
icon?: string;
title?: string;
chevron?: string;
content?: string;
description?: string;
};
export interface BouncyAccordionProps {
items: BouncyAccordionItem[];
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
collapsible?: boolean;
className?: string;
classNames?: BouncyAccordionClassNames;
}
// Local springs keep the accordion's connected groups moving together while
// avoiding scale projection on text-heavy row contents.
// Gap spring: must not overshoot y — positive y overshoot drifts items below
// their mt-3 resting point and briefly overlaps the next item.
const ROW_TRANSITION: Transition = {
type: "spring",
duration: 0.55,
bounce: 0.38,
};
const CONTENT_OPEN_TRANSITION: Transition = {
type: "spring",
duration: 0.58,
bounce: 0.32,
};
const CONTENT_CLOSE_TRANSITION: Transition = {
type: "spring",
duration: 0.46,
bounce: 0.26,
};
const DESCRIPTION_TRANSITION: Transition = {
duration: 0.18,
ease: EASE_OUT,
};
const CHEVRON_TRANSITION: Transition = {
type: "spring",
duration: 0.42,
bounce: 0.28,
};
function useControllableAccordionValue({
value,
defaultValue,
onValueChange,
}: {
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
}) {
const [internalValue, setInternalValue] = useState(defaultValue ?? null);
const isControlled = value !== undefined;
const currentValue = value ?? internalValue;
const setValue = useCallback(
(next: string | null) => {
if (!isControlled) {
setInternalValue(next);
}
onValueChange?.(next);
},
[isControlled, onValueChange],
);
return [currentValue, setValue] as const;
}
function BouncyAccordionRow({
item,
open,
startsGroup,
endsGroup,
separatedFromPrevious,
contentId,
triggerId,
reduce,
classNames,
onToggle,
}: {
item: BouncyAccordionItem;
open: boolean;
startsGroup: boolean;
endsGroup: boolean;
separatedFromPrevious: boolean;
contentId: string;
triggerId: string;
reduce: boolean | null;
classNames?: BouncyAccordionClassNames;
onToggle: () => void;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const updateHeight = () => {
setContentHeight(node.offsetHeight);
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(node);
return () => {
observer.disconnect();
};
}, []);
return (
<motion.div
layout="position"
initial={false}
style={{ marginTop: separatedFromPrevious ? 12 : 0 }}
transition={reduce ? { duration: 0 } : ROW_TRANSITION}
>
<motion.div
data-state={open ? "open" : "closed"}
initial={false}
animate={{
borderTopLeftRadius: startsGroup ? 28 : 0,
borderTopRightRadius: startsGroup ? 28 : 0,
borderBottomLeftRadius: endsGroup ? 28 : 0,
borderBottomRightRadius: endsGroup ? 28 : 0,
}}
transition={reduce ? { duration: 0 } : ROW_TRANSITION}
className={cn(
"overflow-hidden bg-card text-card-foreground",
item.disabled && "opacity-50",
classNames?.item,
)}
>
<button
id={triggerId}
type="button"
disabled={item.disabled}
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className={cn(
"flex min-h-[54px] w-full items-center gap-4 px-5 text-left outline-none transition-colors",
"focus-visible:bg-muted/25",
"disabled:pointer-events-none",
classNames?.trigger,
)}
>
{item.icon ? (
<span
className={cn(
"grid h-7 w-7 shrink-0 place-items-center text-muted-foreground",
classNames?.icon,
)}
>
{item.icon}
</span>
) : null}
<span
className={cn(
"min-w-0 flex-1 truncate text-[15px] font-medium text-foreground",
classNames?.title,
)}
>
{item.title}
</span>
<motion.span
aria-hidden
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : CHEVRON_TRANSITION}
className={cn(
"grid h-6 w-6 shrink-0 place-items-center text-muted-foreground",
classNames?.chevron,
)}
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</button>
<motion.div
layout="size"
id={contentId}
role="region"
aria-labelledby={triggerId}
aria-hidden={!open}
inert={!open}
initial={false}
style={{ height: open && item.description ? contentHeight : 0 }}
transition={
reduce
? { duration: 0 }
: open
? CONTENT_OPEN_TRANSITION
: CONTENT_CLOSE_TRANSITION
}
className={cn("overflow-hidden", classNames?.content)}
>
<motion.div
ref={contentRef}
animate={{
opacity: open ? 1 : 0,
}}
transition={reduce ? { duration: 0 } : DESCRIPTION_TRANSITION}
className="px-5 pb-5"
>
<div
className={cn(
"text-[15px] leading-6 text-muted-foreground",
classNames?.description,
)}
>
{item.description}
</div>
</motion.div>
</motion.div>
</motion.div>
</motion.div>
);
}
export function BouncyAccordion({
items,
value,
defaultValue = null,
onValueChange,
collapsible = true,
className,
classNames,
}: BouncyAccordionProps) {
const reduce = useReducedMotion();
const baseId = useId();
const [activeValue, setActiveValue] = useControllableAccordionValue({
value,
defaultValue,
onValueChange,
});
const activeIndex = items.findIndex((item) => item.id === activeValue);
const toggleItem = useCallback(
(id: string) => {
if (activeValue === id) {
if (collapsible) {
setActiveValue(null);
}
return;
}
setActiveValue(id);
},
[activeValue, collapsible, setActiveValue],
);
return (
<div className={cn("w-full", className, classNames?.root)}>
{items.map((item, index) => {
const open = activeValue === item.id;
const previousIsOpen = activeIndex === index - 1;
const nextIsOpen = activeIndex === index + 1;
const startsGroup = open || index === 0 || previousIsOpen;
const endsGroup = open || index === items.length - 1 || nextIsOpen;
const separatedFromPrevious = index > 0 && (open || previousIsOpen);
const contentId = `${baseId}-${item.id}-content`;
const triggerId = `${baseId}-${item.id}-trigger`;
return (
<BouncyAccordionRow
key={item.id}
item={item}
open={open}
startsGroup={startsGroup}
endsGroup={endsGroup}
separatedFromPrevious={separatedFromPrevious}
contentId={contentId}
triggerId={triggerId}
reduce={reduce}
classNames={classNames}
onToggle={() => toggleItem(item.id)}
/>
);
})}
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/bouncy-accordion
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/bouncy-accordion.tsx
"use client";
// beui.dev/components/motion/bouncy-accordion
import {
motion,
useReducedMotion,
type Transition,
} from "motion/react";
import { ChevronDown } from "lucide-react";
import {
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BouncyAccordionItem = {
id: string;
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
};
export type BouncyAccordionClassNames = {
root?: string;
item?: string;
trigger?: string;
icon?: string;
title?: string;
chevron?: string;
content?: string;
description?: string;
};
export interface BouncyAccordionProps {
items: BouncyAccordionItem[];
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
collapsible?: boolean;
className?: string;
classNames?: BouncyAccordionClassNames;
}
// Local springs keep the accordion's connected groups moving together while
// avoiding scale projection on text-heavy row contents.
// Gap spring: must not overshoot y — positive y overshoot drifts items below
// their mt-3 resting point and briefly overlaps the next item.
const ROW_TRANSITION: Transition = {
type: "spring",
duration: 0.55,
bounce: 0.38,
};
const CONTENT_OPEN_TRANSITION: Transition = {
type: "spring",
duration: 0.58,
bounce: 0.32,
};
const CONTENT_CLOSE_TRANSITION: Transition = {
type: "spring",
duration: 0.46,
bounce: 0.26,
};
const DESCRIPTION_TRANSITION: Transition = {
duration: 0.18,
ease: EASE_OUT,
};
const CHEVRON_TRANSITION: Transition = {
type: "spring",
duration: 0.42,
bounce: 0.28,
};
function useControllableAccordionValue({
value,
defaultValue,
onValueChange,
}: {
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
}) {
const [internalValue, setInternalValue] = useState(defaultValue ?? null);
const isControlled = value !== undefined;
const currentValue = value ?? internalValue;
const setValue = useCallback(
(next: string | null) => {
if (!isControlled) {
setInternalValue(next);
}
onValueChange?.(next);
},
[isControlled, onValueChange],
);
return [currentValue, setValue] as const;
}
function BouncyAccordionRow({
item,
open,
startsGroup,
endsGroup,
separatedFromPrevious,
contentId,
triggerId,
reduce,
classNames,
onToggle,
}: {
item: BouncyAccordionItem;
open: boolean;
startsGroup: boolean;
endsGroup: boolean;
separatedFromPrevious: boolean;
contentId: string;
triggerId: string;
reduce: boolean | null;
classNames?: BouncyAccordionClassNames;
onToggle: () => void;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const updateHeight = () => {
setContentHeight(node.offsetHeight);
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(node);
return () => {
observer.disconnect();
};
}, []);
return (
<motion.div
layout="position"
initial={false}
style={{ marginTop: separatedFromPrevious ? 12 : 0 }}
transition={reduce ? { duration: 0 } : ROW_TRANSITION}
>
<motion.div
data-state={open ? "open" : "closed"}
initial={false}
animate={{
borderTopLeftRadius: startsGroup ? 28 : 0,
borderTopRightRadius: startsGroup ? 28 : 0,
borderBottomLeftRadius: endsGroup ? 28 : 0,
borderBottomRightRadius: endsGroup ? 28 : 0,
}}
transition={reduce ? { duration: 0 } : ROW_TRANSITION}
className={cn(
"overflow-hidden bg-card text-card-foreground",
item.disabled && "opacity-50",
classNames?.item,
)}
>
<button
id={triggerId}
type="button"
disabled={item.disabled}
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className={cn(
"flex min-h-[54px] w-full items-center gap-4 px-5 text-left outline-none transition-colors",
"focus-visible:bg-muted/25",
"disabled:pointer-events-none",
classNames?.trigger,
)}
>
{item.icon ? (
<span
className={cn(
"grid h-7 w-7 shrink-0 place-items-center text-muted-foreground",
classNames?.icon,
)}
>
{item.icon}
</span>
) : null}
<span
className={cn(
"min-w-0 flex-1 truncate text-[15px] font-medium text-foreground",
classNames?.title,
)}
>
{item.title}
</span>
<motion.span
aria-hidden
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : CHEVRON_TRANSITION}
className={cn(
"grid h-6 w-6 shrink-0 place-items-center text-muted-foreground",
classNames?.chevron,
)}
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</button>
<motion.div
layout="size"
id={contentId}
role="region"
aria-labelledby={triggerId}
aria-hidden={!open}
inert={!open}
initial={false}
style={{ height: open && item.description ? contentHeight : 0 }}
transition={
reduce
? { duration: 0 }
: open
? CONTENT_OPEN_TRANSITION
: CONTENT_CLOSE_TRANSITION
}
className={cn("overflow-hidden", classNames?.content)}
>
<motion.div
ref={contentRef}
animate={{
opacity: open ? 1 : 0,
}}
transition={reduce ? { duration: 0 } : DESCRIPTION_TRANSITION}
className="px-5 pb-5"
>
<div
className={cn(
"text-[15px] leading-6 text-muted-foreground",
classNames?.description,
)}
>
{item.description}
</div>
</motion.div>
</motion.div>
</motion.div>
</motion.div>
);
}
export function BouncyAccordion({
items,
value,
defaultValue = null,
onValueChange,
collapsible = true,
className,
classNames,
}: BouncyAccordionProps) {
const reduce = useReducedMotion();
const baseId = useId();
const [activeValue, setActiveValue] = useControllableAccordionValue({
value,
defaultValue,
onValueChange,
});
const activeIndex = items.findIndex((item) => item.id === activeValue);
const toggleItem = useCallback(
(id: string) => {
if (activeValue === id) {
if (collapsible) {
setActiveValue(null);
}
return;
}
setActiveValue(id);
},
[activeValue, collapsible, setActiveValue],
);
return (
<div className={cn("w-full", className, classNames?.root)}>
{items.map((item, index) => {
const open = activeValue === item.id;
const previousIsOpen = activeIndex === index - 1;
const nextIsOpen = activeIndex === index + 1;
const startsGroup = open || index === 0 || previousIsOpen;
const endsGroup = open || index === items.length - 1 || nextIsOpen;
const separatedFromPrevious = index > 0 && (open || previousIsOpen);
const contentId = `${baseId}-${item.id}-content`;
const triggerId = `${baseId}-${item.id}-trigger`;
return (
<BouncyAccordionRow
key={item.id}
item={item}
open={open}
startsGroup={startsGroup}
endsGroup={endsGroup}
separatedFromPrevious={separatedFromPrevious}
contentId={contentId}
triggerId={triggerId}
reduce={reduce}
classNames={classNames}
onToggle={() => toggleItem(item.id)}
/>
);
})}
</div>
);
}
API Reference
items{}—value?string | null—defaultValue?string | nullnullonValueChange?((value: string | null) => void)—collapsible?booleantrueclassName?string—classNames?BouncyAccordionClassNames—Keep in mind
Some 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.
Updated