Bouncy Accordion
Single-open accordion with weighted spring layout, icon rows and reduced-motion-safe content reveals.
Preview
Collect launch notes, owners, and risks in one compact handoff before the release window opens.
Verify copy, links, analytics, rollback steps, and final approvals without leaving the queue.
Keep channel-specific notes close to the task while preserving a calm collapsed list.
Plan announcements, staging checks, reminders, and quiet periods around the same timeline.
Track the current artifact, deploy status, and final sign-off before marking the release complete.
Move final copy, images, and source files into the campaign folder once the rollout is done.
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-accordionbouncy-accordionbouncy-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
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.
Range Slider
Slider with tick dots and a vertical-bar thumb that bounces as it lands on each step. Drag or keyboard, reduced-motion safe.
Wheel Picker
iOS-style picker wheel: a 3D drum on native momentum scroll that snaps to the nearest notch, with wheel, drag and keyboard control. Composes side by side for date and time pickers, reduced-motion safe.
Updated