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