Bouncy Accordion
Single-open accordion with weighted spring layout, icon rows and reduced-motion-safe content reveals.
Preview
"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>
);
}
"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;
}
// Rows, cards and scale-corrected text share a transition so their edges
// stay joined. A close or switch uses the faster non-overshooting spring;
// opening from all-closed keeps the weighted bounce.
const EXPAND_TRANSITION: Transition = {
type: "spring",
duration: 0.58,
bounce: 0.32,
};
const COLLAPSE_TRANSITION: Transition = {
type: "spring",
duration: 0.46,
bounce: 0,
};
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 = isControlled ? 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,
layoutTransition,
layoutDependency,
classNames,
onToggle,
}: {
item: BouncyAccordionItem;
open: boolean;
startsGroup: boolean;
endsGroup: boolean;
separatedFromPrevious: boolean;
contentId: string;
triggerId: string;
reduce: boolean | null;
layoutTransition: Transition;
layoutDependency: string | 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={reduce ? false : "position"}
layoutDependency={layoutDependency}
initial={false}
style={{ marginTop: separatedFromPrevious ? 12 : 0 }}
transition={{ layout: layoutTransition }}
>
<motion.div
data-state={open ? "open" : "closed"}
layout={!reduce}
layoutDependency={layoutDependency}
initial={false}
animate={{
borderTopLeftRadius: startsGroup ? 28 : 0,
borderTopRightRadius: startsGroup ? 28 : 0,
borderBottomLeftRadius: endsGroup ? 28 : 0,
borderBottomRightRadius: endsGroup ? 28 : 0,
}}
transition={reduce ? { duration: 0 } : layoutTransition}
style={{ originY: 0 }}
className={cn(
"overflow-hidden bg-card text-card-foreground",
item.disabled && "opacity-50",
classNames?.item,
)}
>
<motion.button
id={triggerId}
type="button"
disabled={item.disabled}
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
layout={reduce ? false : "position"}
layoutDependency={layoutDependency}
transition={{ layout: layoutTransition }}
style={{ originY: 0 }}
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>
</motion.button>
<section
id={contentId}
aria-labelledby={triggerId}
aria-hidden={!open}
inert={!open}
style={{ height: open && item.description ? contentHeight || "auto" : 0 }}
className={cn(classNames?.content)}
>
<motion.div
ref={contentRef}
// The card clips this full-size layout node. Correcting only the
// card scale avoids a one-pixel clip and its extreme text scale.
// Size changes outside a toggle snap to their natural geometry.
layout={reduce ? false : "position"}
layoutDependency={layoutDependency}
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{
opacity: reduce ? { duration: 0 } : DESCRIPTION_TRANSITION,
layout: layoutTransition,
}}
style={{ originY: 0 }}
className="px-5 pb-5"
>
<div
className={cn(
"text-[15px] leading-6 text-muted-foreground",
classNames?.description,
)}
>
{item.description}
</div>
</motion.div>
</section>
</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 previousValue = useRef(activeValue);
const layoutTransition =
activeValue === null || previousValue.current !== null
? COLLAPSE_TRANSITION
: EXPAND_TRANSITION;
useLayoutEffect(() => {
previousValue.current = activeValue;
}, [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}
layoutTransition={layoutTransition}
layoutDependency={activeValue}
classNames={classNames}
onToggle={() => toggleItem(item.id)}
/>
);
})}
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"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;
}
// Rows, cards and scale-corrected text share a transition so their edges
// stay joined. A close or switch uses the faster non-overshooting spring;
// opening from all-closed keeps the weighted bounce.
const EXPAND_TRANSITION: Transition = {
type: "spring",
duration: 0.58,
bounce: 0.32,
};
const COLLAPSE_TRANSITION: Transition = {
type: "spring",
duration: 0.46,
bounce: 0,
};
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 = isControlled ? 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,
layoutTransition,
layoutDependency,
classNames,
onToggle,
}: {
item: BouncyAccordionItem;
open: boolean;
startsGroup: boolean;
endsGroup: boolean;
separatedFromPrevious: boolean;
contentId: string;
triggerId: string;
reduce: boolean | null;
layoutTransition: Transition;
layoutDependency: string | 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={reduce ? false : "position"}
layoutDependency={layoutDependency}
initial={false}
style={{ marginTop: separatedFromPrevious ? 12 : 0 }}
transition={{ layout: layoutTransition }}
>
<motion.div
data-state={open ? "open" : "closed"}
layout={!reduce}
layoutDependency={layoutDependency}
initial={false}
animate={{
borderTopLeftRadius: startsGroup ? 28 : 0,
borderTopRightRadius: startsGroup ? 28 : 0,
borderBottomLeftRadius: endsGroup ? 28 : 0,
borderBottomRightRadius: endsGroup ? 28 : 0,
}}
transition={reduce ? { duration: 0 } : layoutTransition}
style={{ originY: 0 }}
className={cn(
"overflow-hidden bg-card text-card-foreground",
item.disabled && "opacity-50",
classNames?.item,
)}
>
<motion.button
id={triggerId}
type="button"
disabled={item.disabled}
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
layout={reduce ? false : "position"}
layoutDependency={layoutDependency}
transition={{ layout: layoutTransition }}
style={{ originY: 0 }}
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>
</motion.button>
<section
id={contentId}
aria-labelledby={triggerId}
aria-hidden={!open}
inert={!open}
style={{ height: open && item.description ? contentHeight || "auto" : 0 }}
className={cn(classNames?.content)}
>
<motion.div
ref={contentRef}
// The card clips this full-size layout node. Correcting only the
// card scale avoids a one-pixel clip and its extreme text scale.
// Size changes outside a toggle snap to their natural geometry.
layout={reduce ? false : "position"}
layoutDependency={layoutDependency}
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{
opacity: reduce ? { duration: 0 } : DESCRIPTION_TRANSITION,
layout: layoutTransition,
}}
style={{ originY: 0 }}
className="px-5 pb-5"
>
<div
className={cn(
"text-[15px] leading-6 text-muted-foreground",
classNames?.description,
)}
>
{item.description}
</div>
</motion.div>
</section>
</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 previousValue = useRef(activeValue);
const layoutTransition =
activeValue === null || previousValue.current !== null
? COLLAPSE_TRANSITION
: EXPAND_TRANSITION;
useLayoutEffect(() => {
previousValue.current = activeValue;
}, [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}
layoutTransition={layoutTransition}
layoutDependency={activeValue}
classNames={classNames}
onToggle={() => toggleItem(item.id)}
/>
);
})}
</div>
);
}
API Reference
itemsBouncyAccordionItem[]—value?string | null—defaultValue?string | nullnullonValueChange?((value: string | null) => void)—collapsible?booleantrueclassName?string—classNames?BouncyAccordionClassNames—Related components
Table
Virtualized data table that stays smooth at 10k+ rows, with sortable headers, row selection, column resize and reorder, and a sticky header. Minimal, reduced-motion-safe motion.
Sortable List
Composable sortable list with group, item, drag handle, content and undo parts, plus a shared state hook. Spring layout, keyboard reordering, accessible position announcements, and controlled or uncontrolled items.
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter with a liquid neck that stretches and pinches, then crisp content fades in on top. The Morph variant reveals its full-size panel from the corner nearest the trigger on one synchronized spring clip. Click or hover trigger, controlled or uncontrolled.
Updated