{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "accordion",
  "title": "Accordion",
  "description": "Collapsible accordion with spring-animated height and chevron, and proximity hover across every row.",
  "dependencies": [
    "motion",
    "@base-ui/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "springs",
    "font-weight",
    "use-proximity-hover"
  ],
  "files": [
    {
      "path": "src/components/ui/accordion.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  isValidElement,\n  cloneElement,\n  useRef,\n  useState,\n  useEffect,\n  useLayoutEffect,\n  useCallback,\n  useMemo,\n  createContext,\n  useContext,\n  forwardRef,\n  type ReactElement,\n  type ReactNode,\n  type HTMLAttributes,\n} from \"react\";\nimport { motion, AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { Accordion as AccordionPrimitive } from \"@base-ui/react/accordion\";\nimport { ChevronRight } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { fontWeights } from \"@/lib/font-weight\";\nimport { ProximityHoverPill } from \"@/components/ui/proximity-hover-pill\";\nimport { useProximityHover, type ItemRect } from \"@/hooks/use-proximity-hover\";\n\n// SSR-safe layout effect (client components still server-render in Next).\nconst useIsoLayoutEffect = typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n// ─── Contexts ────────────────────────────────────────────────────────────────\n\ninterface AccordionContextValue {\n  registerItem: (index: number, element: HTMLElement | null) => void;\n  registerFullItem: (index: number, element: HTMLElement | null) => void;\n  activeIndex: number | null;\n  remeasure: () => void;\n  openValues: Set<string>;\n  openItemRects: Map<number, ItemRect>;\n}\n\nconst AccordionContext = createContext<AccordionContextValue | null>(null);\n\nfunction useAccordionContext() {\n  const ctx = useContext(AccordionContext);\n  if (!ctx) throw new Error(\"AccordionItem must be used within an Accordion\");\n  return ctx;\n}\n\ninterface AccordionItemContextValue {\n  index?: number;\n  value: string;\n  isOpen: boolean;\n  triggerRef: React.MutableRefObject<HTMLDivElement | null>;\n}\n\nconst AccordionItemContext = createContext<AccordionItemContextValue | null>(null);\n\nfunction useAccordionItemContext() {\n  const ctx = useContext(AccordionItemContext);\n  if (!ctx)\n    throw new Error(\"AccordionTrigger/AccordionContent must be used within an AccordionItem\");\n  return ctx;\n}\n\n// ─── Accordion ───────────────────────────────────────────────────────────────\n// Every item shares one container: a background pill morphs between the item\n// nearest the cursor and the currently open item(s) — \"proximity hover\"\n// applied to an accordion stack. Applies uniformly whether the accordion\n// holds one item or many — it looks and moves identically no matter which\n// pattern embeds it.\n\ntype AccordionSingleProps = {\n  type?: \"single\";\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n};\n\ntype AccordionMultipleProps = {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\ntype AccordionProps = Omit<HTMLAttributes<HTMLDivElement>, \"onFocus\" | \"onBlur\"> & {\n  children: ReactNode;\n} & (AccordionSingleProps | AccordionMultipleProps);\n\nconst Accordion = forwardRef<HTMLDivElement, AccordionProps>((props, ref) => {\n  const { children, type = \"single\", className, ...rest } = props;\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const fullItemElementsRef = useRef<Map<number, HTMLElement>>(new Map());\n  const [openItemRects, setOpenItemRects] = useState<Map<number, ItemRect>>(new Map());\n  const openItemRectsRef = useRef(openItemRects);\n\n  const {\n    activeIndex,\n    setActiveIndex,\n    itemRects,\n    sessionRef,\n    handlers,\n    registerItem,\n    measureItems,\n  } = useProximityHover(containerRef);\n\n  const registerFullItem = useCallback((index: number, element: HTMLElement | null) => {\n    if (element) fullItemElementsRef.current.set(index, element);\n    else fullItemElementsRef.current.delete(index);\n  }, []);\n\n  const measureFullItems = useCallback(() => {\n    if (!containerRef.current) return;\n    const next = new Map<number, ItemRect>();\n    fullItemElementsRef.current.forEach((el, idx) => {\n      next.set(idx, {\n        top: el.offsetTop,\n        left: el.offsetLeft,\n        width: el.offsetWidth,\n        height: el.offsetHeight,\n      });\n    });\n    // Skip the state update when nothing moved (mirrors the proximity hook's\n    // measureItems guard) — this runs per animation frame via onUpdate, and\n    // an unconditional set would invalidate the group context and re-render\n    // every item even on no-op remeasures.\n    const prev = openItemRectsRef.current;\n    let changed = prev.size !== next.size;\n    if (!changed) {\n      for (const [idx, r] of next) {\n        const p = prev.get(idx);\n        if (\n          !p ||\n          p.top !== r.top ||\n          p.left !== r.left ||\n          p.width !== r.width ||\n          p.height !== r.height\n        ) {\n          changed = true;\n          break;\n        }\n      }\n    }\n    if (!changed) return;\n    openItemRectsRef.current = next;\n    setOpenItemRects(next);\n  }, []);\n\n  const [internalSingleValue, setInternalSingleValue] = useState<string>(() =>\n    type === \"single\" ? ((props as AccordionSingleProps).defaultValue ?? \"\") : \"\",\n  );\n  const [internalMultipleValue, setInternalMultipleValue] = useState<string[]>(() =>\n    type === \"multiple\" ? ((props as AccordionMultipleProps).defaultValue ?? []) : [],\n  );\n\n  const openValuesList: string[] =\n    type === \"multiple\"\n      ? ((props as AccordionMultipleProps).value ?? internalMultipleValue)\n      : (() => {\n          const v = (props as AccordionSingleProps).value ?? internalSingleValue;\n          return v ? [v] : [];\n        })();\n\n  // Keyed on the joined values so the Set (and the group context value below)\n  // keeps a stable identity across re-renders where the open values haven't\n  // actually changed.\n  const openValuesKey = openValuesList.join(\",\");\n  const openValues = useMemo(() => new Set(openValuesList), [openValuesKey]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    measureItems();\n    measureFullItems();\n  }, [measureItems, measureFullItems, children]);\n\n  useEffect(() => {\n    measureItems();\n    measureFullItems();\n  }, [measureItems, measureFullItems, openValuesKey]);\n\n  const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;\n  const isHoveringNonOpen = activeIndex !== null && !openItemRects.has(activeIndex);\n\n  const remeasure = useCallback(() => {\n    measureItems();\n    measureFullItems();\n  }, [measureItems, measureFullItems]);\n\n  // Translate the single/multiple public API → Base UI's Accordion API,\n  // which always uses `value: string[]` plus a `multiple: boolean`. In\n  // single mode the active value is wrapped in a single-element array.\n  const baseValue: string[] =\n    type === \"multiple\"\n      ? ((props as AccordionMultipleProps).value ?? internalMultipleValue)\n      : (() => {\n          const v = (props as AccordionSingleProps).value ?? internalSingleValue;\n          return v ? [v] : [];\n        })();\n\n  const baseOnValueChange = (next: string[]) => {\n    if (type === \"multiple\") {\n      const mp = props as AccordionMultipleProps;\n      if (mp.onValueChange) mp.onValueChange(next);\n      else setInternalMultipleValue(next);\n    } else {\n      const sp = props as AccordionSingleProps;\n      if (sp.onValueChange) sp.onValueChange(next[0] ?? \"\");\n      else setInternalSingleValue(next[0] ?? \"\");\n    }\n  };\n\n  // Memoized: the group re-renders on every proximity-hover mousemove; a\n  // fresh context object each time would re-render every item with it.\n  const contextValue = useMemo<AccordionContextValue>(\n    () => ({ registerItem, registerFullItem, activeIndex, remeasure, openValues, openItemRects }),\n    [registerItem, registerFullItem, activeIndex, remeasure, openValues, openItemRects],\n  );\n\n  const {\n    value: _value,\n    defaultValue: _defaultValue,\n    onValueChange: _onValueChange,\n    ...htmlProps\n  } = rest as Record<string, unknown>;\n\n  // Auto-index items by position so callers never have to hand-thread an\n  // `index` prop just to get proximity hover — pass one explicitly only to\n  // override the child order (e.g. a fragment-wrapped item).\n  const indexedChildren = Children.map(children, (child, position) => {\n    if (!isValidElement(child)) return child;\n    const el = child as ReactElement<{ index?: number }>;\n    return el.props.index !== undefined ? el : cloneElement(el, { index: position });\n  });\n\n  return (\n    <AccordionContext.Provider value={contextValue}>\n      <AccordionPrimitive.Root\n        value={baseValue}\n        onValueChange={baseOnValueChange}\n        multiple={type === \"multiple\"}\n        ref={(node: HTMLDivElement | null) => {\n          (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n        }}\n        data-slot=\"accordion\"\n        onMouseEnter={handlers.onMouseEnter}\n        onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {\n          // While the cursor is over an *open* item's content (below its\n          // trigger), suspend proximity hover — the hover pill shouldn't\n          // compete with the persistent open-item tint underneath it.\n          const container = containerRef.current;\n          if (container) {\n            const cRect = container.getBoundingClientRect();\n            const localY = e.clientY - cRect.top + container.scrollTop;\n            for (const [idx, full] of openItemRects) {\n              const trigger = itemRects[idx];\n              if (!trigger) continue;\n              const contentTop = trigger.top + trigger.height;\n              const contentBottom = full.top + full.height;\n              if (localY >= contentTop && localY <= contentBottom) {\n                setActiveIndex(null);\n                return;\n              }\n            }\n          }\n          handlers.onMouseMove(e);\n        }}\n        onMouseLeave={handlers.onMouseLeave}\n        className={cn(\"relative flex w-full flex-col gap-0.5\", className)}\n        {...(htmlProps as Omit<HTMLAttributes<HTMLDivElement>, \"defaultValue\">)}\n      >\n        {/* Expanded item backgrounds — persistent, low-opacity tint under\n            whichever item(s) are open. Geometry snaps (duration 0) so it\n            hugs the item through its own height-spring animation; only\n            opacity fades. */}\n        <AnimatePresence>\n          {[...openItemRects.entries()].map(([idx, rect]) => (\n            <motion.div\n              key={`expanded-${idx}`}\n              className=\"pointer-events-none absolute rounded-lg bg-accent/20 dark:bg-accent/12\"\n              initial={{\n                top: rect.top,\n                left: rect.left,\n                width: rect.width,\n                height: rect.height,\n                opacity: 0,\n              }}\n              animate={{\n                top: rect.top,\n                left: rect.left,\n                width: rect.width,\n                height: rect.height,\n                opacity: isHoveringNonOpen ? 0.7 : 1,\n              }}\n              exit={{ opacity: 0, transition: spring.moderate.exit }}\n              transition={{\n                top: { duration: 0 },\n                left: { duration: 0 },\n                width: { duration: 0 },\n                height: { duration: 0 },\n                opacity: { duration: 0.12 },\n              }}\n            />\n          ))}\n        </AnimatePresence>\n\n        {/* Hover pill — tracks the item nearest the cursor. A faint\n            foreground-tinted wash (not --accent), capped below full\n            layer-opacity (see use-proximity-hover.ts) so it stays clearly\n            subordinate to the persistent bg-accent/20 expanded-item\n            background above, not a second \"expanded\" look. */}\n        <ProximityHoverPill activeRect={activeRect} sessionKey={sessionRef.current} />\n\n        {indexedChildren}\n      </AccordionPrimitive.Root>\n    </AccordionContext.Provider>\n  );\n});\nAccordion.displayName = \"Accordion\";\n\n// ─── AccordionItem ───────────────────────────────────────────────────────────\n\ninterface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {\n  value: string;\n  /** Position for proximity hover — auto-assigned from child order; pass explicitly only to override it. */\n  index?: number;\n  disabled?: boolean;\n  children: ReactNode;\n}\n\nconst AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(\n  ({ value, index, disabled, children, className, ...props }, ref) => {\n    const internalRef = useRef<HTMLDivElement>(null);\n    const ctx = useAccordionContext();\n\n    const isOpen = ctx.openValues.has(value);\n    const triggerRef = useRef<HTMLDivElement>(null);\n\n    useEffect(() => {\n      if (index === undefined) return;\n      ctx.registerItem(index, triggerRef.current);\n      return () => ctx.registerItem(index, null);\n    }, [index, ctx]);\n\n    useEffect(() => {\n      if (index === undefined) return;\n      ctx.registerFullItem(index, isOpen ? internalRef.current : null);\n      return () => ctx.registerFullItem(index, null);\n    }, [index, ctx, isOpen]);\n\n    return (\n      <AccordionItemContext.Provider value={{ index, value, isOpen, triggerRef }}>\n        <AccordionPrimitive.Item\n          value={value}\n          disabled={disabled}\n          ref={(node: HTMLDivElement | null) => {\n            (internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n          }}\n          data-slot=\"accordion-item\"\n          data-proximity-index={index}\n          className={className}\n          {...props}\n        >\n          {children}\n        </AccordionPrimitive.Item>\n      </AccordionItemContext.Provider>\n    );\n  },\n);\nAccordionItem.displayName = \"AccordionItem\";\n\n// ─── AccordionTrigger ────────────────────────────────────────────────────────\n\ninterface AccordionTriggerProps extends HTMLAttributes<HTMLButtonElement> {\n  children: ReactNode;\n}\n\nconst AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(\n  ({ children, className, ...props }, ref) => {\n    const ctx = useAccordionContext();\n    const { index, isOpen, triggerRef } = useAccordionItemContext();\n\n    const isActive = ctx.activeIndex === index;\n\n    const triggerContent = (\n      // Header renders as a <div>: Base UI's default <h3> would be more\n      // semantic but the styles above key off it being a plain flex child.\n      <AccordionPrimitive.Header render={<div />} data-slot=\"accordion-header\">\n        <AccordionPrimitive.Trigger\n          ref={ref}\n          data-slot=\"accordion-trigger\"\n          className={cn(\n            \"relative z-10 flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n            className,\n          )}\n          {...props}\n        >\n          {/* Ghost-span: an invisible copy at the heaviest weight reserves\n              the width so the visible copy's weight can animate without\n              reflowing the row. */}\n          <span className=\"col-start-1 row-start-1 grid flex-1 text-left text-body\">\n            <span\n              className=\"invisible col-start-1 row-start-1\"\n              style={{ fontVariationSettings: fontWeights.medium }}\n              aria-hidden=\"true\"\n            >\n              {children}\n            </span>\n            <span\n              className={cn(\n                \"col-start-1 row-start-1 transition-colors duration-fast\",\n                isOpen || isActive ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n              style={{ fontVariationSettings: isOpen ? fontWeights.medium : fontWeights.normal }}\n            >\n              {children}\n            </span>\n          </span>\n\n          <motion.span\n            className=\"inline-flex shrink-0 items-center justify-center\"\n            animate={{ rotate: isOpen ? 90 : 0 }}\n            transition={spring.fast.enter}\n          >\n            <ChevronRight\n              size={16}\n              strokeWidth={isOpen || isActive ? 2 : 1.5}\n              className={cn(\n                \"transition-colors duration-fast\",\n                isOpen || isActive ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n            />\n          </motion.span>\n        </AccordionPrimitive.Trigger>\n      </AccordionPrimitive.Header>\n    );\n\n    return <div ref={triggerRef}>{triggerContent}</div>;\n  },\n);\nAccordionTrigger.displayName = \"AccordionTrigger\";\n\n// ─── AccordionContent ────────────────────────────────────────────────────────\n\ninterface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {\n  children: ReactNode;\n}\n\nconst AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(\n  ({ children, className, ...props }, ref) => {\n    const ctx = useAccordionContext();\n    const { isOpen } = useAccordionItemContext();\n\n    // The open height animates to a self-measured LAYOUT pixel value, not\n    // `height: \"auto\"`: framer resolves an \"auto\" target by measuring the\n    // element's *visual* (transformed) size, so under a scaled ancestor the\n    // animation would overshoot and snap back at the end of every open.\n    // offsetHeight and ResizeObserver are transform-immune.\n    const innerRef = useRef<HTMLDivElement | null>(null);\n    const roRef = useRef<ResizeObserver | null>(null);\n    const [contentHeight, setContentHeight] = useState<number | null>(null);\n    // Items open at mount must SNAP (duration 0) on their first pixel target,\n    // not spring — framer would measure the spring's numeric start visually\n    // and play a shrink. Items that open later spring normally.\n    const needsSnap = useRef(isOpen);\n    const reduceMotion = useReducedMotion();\n\n    const measureRef = useCallback((el: HTMLDivElement | null) => {\n      roRef.current?.disconnect();\n      roRef.current = null;\n      innerRef.current = el;\n      if (!el) return;\n      if (el.offsetHeight > 0) setContentHeight(el.offsetHeight);\n      const ro = new ResizeObserver(() => {\n        if (el.offsetHeight > 0) setContentHeight(el.offsetHeight);\n      });\n      ro.observe(el);\n      roRef.current = ro;\n    }, []);\n\n    // Re-measure synchronously (pre-paint) when opening, so the spring's\n    // target is the fresh layout height from its first frame.\n    useIsoLayoutEffect(() => {\n      if (isOpen && innerRef.current && innerRef.current.offsetHeight > 0) {\n        setContentHeight(innerRef.current.offsetHeight);\n      }\n    }, [isOpen]);\n\n    useEffect(() => {\n      if (contentHeight !== null) needsSnap.current = false;\n    }, [contentHeight]);\n\n    // Whether the motion height exit animation has fully finished.\n    // Base UI's Panel would apply `hidden` the moment a controlled item\n    // closes, which is `display: none` and would freeze the exit animation\n    // mid-flight — so `hidden` is taken over below and only applied once the\n    // exit has actually completed.\n    const [exitComplete, setExitComplete] = useState(!isOpen);\n    if (isOpen && exitComplete) {\n      // Reset during render so the panel is un-hidden before the opening\n      // animation's first paint.\n      setExitComplete(false);\n    }\n\n    // Rendered through `<Panel keepMounted>` so the panel element persists\n    // through the exit animation and the trigger ↔ panel ARIA contract stays\n    // intact (role=\"region\", aria-labelledby, the id Trigger's\n    // aria-controls points to). The motion height animation lives one\n    // level down inside the persistent panel element.\n    return (\n      <AccordionPrimitive.Panel\n        keepMounted\n        hidden={!isOpen && exitComplete}\n        data-slot=\"accordion-content\"\n      >\n        <motion.div\n          ref={ref}\n          className={cn(\"overflow-hidden\", className)}\n          initial={{ height: isOpen ? \"auto\" : 0 }}\n          animate={{ height: isOpen ? (contentHeight ?? 0) : 0 }}\n          // bounce: 0 — pure height looks better without overshoot (moderate is already bounce 0).\n          transition={needsSnap.current || reduceMotion ? { duration: 0 } : spring.moderate.enter}\n          onUpdate={() => ctx.remeasure()}\n          onAnimationComplete={() => {\n            ctx.remeasure();\n            if (!isOpen) setExitComplete(true);\n          }}\n          // AccordionContentProps is HTMLAttributes<HTMLDivElement>, but framer\n          // motion's HTMLMotionProps types a few overlapping event handlers\n          // (onDrag, onAnimationStart, ...) differently — cast to sidestep the\n          // structural mismatch rather than hand-filter every conflicting key.\n          {...(props as Record<string, unknown>)}\n        >\n          {/* Let the container establish space before its copy arrives. On\n              close, the quicker shared exit gets the old copy out of the\n              way before the row has fully collapsed. */}\n          <motion.div\n            ref={measureRef}\n            className=\"px-3 pt-1 pb-3 text-caption text-muted-foreground\"\n            initial={false}\n            animate={{ opacity: isOpen ? 1 : 0, y: reduceMotion ? 0 : isOpen ? 0 : 3 }}\n            transition={\n              reduceMotion\n                ? spring.quick.exit\n                : isOpen\n                  ? { ...spring.quick.enter, delay: 0.06 }\n                  : spring.quick.exit\n            }\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      </AccordionPrimitive.Panel>\n    );\n  },\n);\nAccordionContent.displayName = \"AccordionContent\";\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\nexport default Accordion;\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}