{ "$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(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,\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 \n );\n};\n\nconst AccordionItem = ({\n className,\n children,\n ...props\n}: ComponentProps) => {\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 \n \n {children}\n \n \n );\n};\n\ntype AccordionTriggerProps = ComponentProps & {\n chevron?: boolean;\n};\n\nconst AccordionTrigger = ({\n ref,\n className,\n children,\n chevron = true,\n ...props\n}: AccordionTriggerProps) => {\n const triggerRef = useRef(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 \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
\n \n \n
\n ) : null}\n \n
\n );\n};\n\ntype AccordionContentProps = ComponentProps &\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 \n );\n};\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n", "type": "registry:ui", "target": "" } ], "type": "registry:ui" }