{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "accordion",
  "title": "Accordion",
  "author": "Matthew Blode",
  "description": "A vertically stacked set of interactive headings that reveal or hide associated content.",
  "dependencies": ["@base-ui/react", "motion"],
  "files": [
    {
      "path": "ui/accordion.tsx",
      "content": "\"use client\";\n\nimport { Accordion as AccordionPrimitive } from \"@base-ui/react/accordion\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport type { HTMLMotionProps, Transition } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type { ComponentProps } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface AccordionItemContextType {\n  canAnimate: boolean;\n  isOpen: boolean;\n  setCanAnimate: (canAnimate: boolean) => void;\n  setIsOpen: (open: boolean) => void;\n}\n\nconst AccordionItemContext = createContext<AccordionItemContextType | undefined>(undefined);\n\nconst useAccordionItem = (): AccordionItemContextType => {\n  const context = useContext(AccordionItemContext);\n  if (!context) {\n    throw new Error(\"useAccordionItem must be used within an AccordionItem\");\n  }\n  return context;\n};\n\ntype AccordionValue = string | string[];\n\ntype AccordionProps = Omit<\n  ComponentProps<typeof AccordionPrimitive.Root>,\n  \"defaultValue\" | \"multiple\" | \"onValueChange\" | \"value\"\n> & {\n  type?: \"single\" | \"multiple\";\n  collapsible?: boolean;\n  value?: AccordionValue;\n  defaultValue?: AccordionValue;\n  onValueChange?: (value: AccordionValue) => void;\n};\n\nconst normalizeAccordionValue = (value: AccordionValue | undefined): string[] | undefined => {\n  if (value === undefined) {\n    return undefined;\n  }\n\n  if (Array.isArray(value)) {\n    return value;\n  }\n\n  return value === \"\" ? [] : [value];\n};\n\nconst Accordion = ({\n  type = \"single\",\n  collapsible = false,\n  value,\n  defaultValue,\n  onValueChange,\n  ...props\n}: AccordionProps) => {\n  const multiple = type === \"multiple\";\n\n  const handleValueChange = useCallback(\n    (nextValue: (unknown | null)[]) => {\n      if (!onValueChange) {\n        return;\n      }\n\n      const nextStringValues = nextValue.filter((item): item is string => typeof item === \"string\");\n\n      if (multiple) {\n        onValueChange(nextStringValues);\n        return;\n      }\n\n      const [firstValue] = nextStringValues;\n      if (firstValue !== undefined) {\n        onValueChange(firstValue);\n        return;\n      }\n\n      if (collapsible) {\n        onValueChange(\"\");\n      }\n    },\n    [collapsible, multiple, onValueChange],\n  );\n\n  return (\n    <AccordionPrimitive.Root\n      data-slot=\"accordion\"\n      defaultValue={normalizeAccordionValue(defaultValue)}\n      multiple={multiple}\n      onValueChange={onValueChange ? handleValueChange : undefined}\n      value={normalizeAccordionValue(value)}\n      {...props}\n    />\n  );\n};\n\nconst AccordionItem = ({\n  className,\n  children,\n  ...props\n}: ComponentProps<typeof AccordionPrimitive.Item>) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const [canAnimate, setCanAnimate] = useState(false);\n\n  const contextValue = useMemo(\n    () => ({ canAnimate, isOpen, setCanAnimate, setIsOpen }),\n    [canAnimate, isOpen],\n  );\n\n  return (\n    <AccordionItemContext.Provider value={contextValue}>\n      <AccordionPrimitive.Item\n        className={cn(\"border-b last:border-b-0\", className)}\n        data-slot=\"accordion-item\"\n        {...props}\n      >\n        {children}\n      </AccordionPrimitive.Item>\n    </AccordionItemContext.Provider>\n  );\n};\n\ntype AccordionTriggerProps = ComponentProps<typeof AccordionPrimitive.Trigger> & {\n  chevron?: boolean;\n};\n\nconst AccordionTrigger = ({\n  ref,\n  className,\n  children,\n  chevron = true,\n  ...props\n}: AccordionTriggerProps) => {\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);\n  const { isOpen, setIsOpen, canAnimate, setCanAnimate } = useAccordionItem();\n\n  useEffect(() => {\n    const node = triggerRef.current;\n    if (!node) {\n      return;\n    }\n\n    const updateState = () => {\n      const isExpanded =\n        Object.hasOwn(node.dataset, \"panelOpen\") || node.getAttribute(\"aria-expanded\") === \"true\";\n      setIsOpen(isExpanded);\n    };\n\n    const observer = new MutationObserver((mutationsList) => {\n      for (const mutation of mutationsList) {\n        if (\n          mutation.attributeName === \"data-panel-open\" ||\n          mutation.attributeName === \"aria-expanded\"\n        ) {\n          updateState();\n        }\n      }\n    });\n\n    observer.observe(node, {\n      attributeFilter: [\"data-panel-open\", \"aria-expanded\"],\n      attributes: true,\n    });\n\n    // On initial `defaultValue`, Base UI applies the panel's `data-panel-open`\n    // after this effect runs, and that first attribute write can land in a gap\n    // the observer misses — leaving the panel collapsed until the user toggles\n    // it. Re-read the open state across the first few frames so we reliably\n    // catch it whenever Base UI writes it, then enable animation.\n    let frameId = 0;\n    let frames = 0;\n    const syncOpenState = () => {\n      updateState();\n      frames += 1;\n      if (frames < 5) {\n        frameId = requestAnimationFrame(syncOpenState);\n      } else {\n        setCanAnimate(true);\n      }\n    };\n    syncOpenState();\n\n    return () => {\n      cancelAnimationFrame(frameId);\n      observer.disconnect();\n    };\n  }, [setCanAnimate, setIsOpen]);\n\n  return (\n    <AccordionPrimitive.Header className=\"flex\">\n      <AccordionPrimitive.Trigger\n        className={cn(\n          \"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-[color,background-color,border-color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n        data-slot=\"accordion-trigger\"\n        ref={triggerRef}\n        {...props}\n      >\n        {children}\n        {chevron ? (\n          // Odd box and bar sizes (11px / 1px) keep each bar's centred offset a\n          // whole number — (11 / 2) - (1 / 2) = 5px. At even sizes the 1.5px bar\n          // landed on 5.25px, straddling two pixels and rendering soft.\n          <div className=\"relative flex h-[11px] w-[11px] shrink-0 items-center justify-center\">\n            <motion.div\n              animate={{ rotate: isOpen ? 180 : 0 }}\n              className=\"absolute top-1/2 left-1/2 h-px w-[11px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground\"\n              transition={\n                canAnimate ? { duration: 0.3, ease: [0.645, 0.045, 0.355, 1] } : { duration: 0 }\n              }\n            />\n            <motion.div\n              animate={{ rotateZ: isOpen ? 90 : 0, scale: isOpen ? 0 : 1 }}\n              className=\"absolute top-1/2 left-1/2 h-[11px] w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground\"\n              style={{ transformOrigin: \"center\" }}\n              transition={\n                canAnimate ? { duration: 0.3, ease: [0.645, 0.045, 0.355, 1] } : { duration: 0 }\n              }\n            />\n          </div>\n        ) : null}\n      </AccordionPrimitive.Trigger>\n    </AccordionPrimitive.Header>\n  );\n};\n\ntype AccordionContentProps = ComponentProps<typeof AccordionPrimitive.Panel> &\n  HTMLMotionProps<\"div\"> & {\n    transition?: Transition;\n  };\n\nconst DEFAULT_ACCORDION_TRANSITION: Transition = {\n  damping: 22,\n  stiffness: 150,\n  type: \"spring\",\n};\n\nconst AccordionContent = ({\n  className,\n  children,\n  transition = DEFAULT_ACCORDION_TRANSITION,\n  ...props\n}: AccordionContentProps) => {\n  const { isOpen, canAnimate } = useAccordionItem();\n\n  return (\n    <AccordionPrimitive.Panel keepMounted {...props} hidden={false}>\n      <AnimatePresence initial={false}>\n        {isOpen ? (\n          <motion.div\n            animate={{ \"--mask-stop\": \"100%\", height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            data-slot=\"accordion-content\"\n            exit={{ \"--mask-stop\": \"0%\", height: 0, opacity: 0 }}\n            initial={canAnimate ? { \"--mask-stop\": \"0%\", height: 0, opacity: 0 } : false}\n            key=\"accordion-content\"\n            style={{\n              WebkitMaskImage:\n                \"linear-gradient(black var(--mask-stop), transparent var(--mask-stop))\",\n              maskImage: \"linear-gradient(black var(--mask-stop), transparent var(--mask-stop))\",\n            }}\n            transition={canAnimate ? transition : { duration: 0 }}\n          >\n            <div className={cn(\"pt-0 pb-4 text-sm leading-[1.5]\", className)}>{children}</div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </AccordionPrimitive.Panel>\n  );\n};\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
