Bouncy Accordion
An FAQ-style list where one row opens at a time: the open row breaks away from its neighbours with a bouncy gap and rounded corners, its content expands to its measured height, and rows can carry icons. Collapsible, controlled or uncontrolled.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/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";
// karaui.kamisama-v0.my.id/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>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
itemsBouncyAccordionItem[]—value?string | null—defaultValue?string | nullnullonValueChange?((value: string | null) => void)—collapsible?booleantrueclassName?string—classNames?BouncyAccordionClassNames—Updated