{
  "slug": "accordion",
  "name": "Blur Reveal Accordion",
  "description": "Interactive accordion with synchronized height expansion and smooth letter-by-letter blur text reveals.",
  "summary": "An ultra-smooth accordion with character-by-character optical blur reveal physics, spring-calibrated toggle morphing, and synchronized height transitions. Features click-outside auto-collapse, dark/light adaptive styling, and customizable spring physics for silky-smooth typography blooming.",
  "category": "accordion",
  "tags": [
    "accordion",
    "collapse",
    "motion",
    "blur-text",
    "reveal",
    "spring",
    "letters",
    "interactive"
  ],
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion"
  ],
  "version": "1.0.0",
  "createdDate": "2026-09-22",
  "updatedDate": "2026-09-22",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Staggered letter-by-letter blur-to-sharp optical reveal animation",
    "Spring-calibrated morphing indicator transitioning smoothly between plus and minus",
    "Height transition on open (380ms) and close (280ms)",
    "Automatic click-outside detection to dismiss expanded panels",
    "Full word-boundary containment preventing mid-word line breaks during stagger",
    "Complete prefers-reduced-motion fallback ensuring instant zero-latency transitions"
  ],
  "anatomy": [
    "<Accordion> (Root container with gradient background shell and click-outside handler)",
    "<button> (Accessible toggle header with animated morphing plus/minus indicator)",
    "<BlurText> (Character-by-character blooming typography engine with spring physics)"
  ],
  "physics": {
    "engine": "Motion Spring & Bezier Filter Engine",
    "description": "Dual-axis height bezier curve combined with per-character optical blur dissolving and spring dampening.",
    "parameters": [
      {
        "label": "Height Transition",
        "value": "380ms [0.16, 1, 0.3, 1]"
      },
      {
        "label": "Letter Spring",
        "value": "stiffness: 180, damping: 22, mass: 0.6"
      },
      {
        "label": "Blur Dissolve",
        "value": "8px -> 0px [0.22, 1, 0.36, 1]"
      },
      {
        "label": "Stagger Pitch",
        "value": "Dynamic 7ms - 16ms per character"
      },
      {
        "label": "Indicator Spring",
        "value": "stiffness: 320, damping: 24"
      },
      {
        "label": "Word Protection",
        "value": "inline-block whitespace-nowrap"
      },
      {
        "label": "Compositing",
        "value": "GPU filter + transform"
      }
    ]
  },
  "accessibility": {
    "role": "region",
    "aria": "Triggers are buttons with aria-expanded. Each open panel is a role=\"region\" labelled by its trigger and referenced by aria-controls. The blurred-in description words are aria-hidden inside a paragraph whose aria-label holds the full text.",
    "reducedMotion": "With prefers-reduced-motion, height and opacity change instantly and the letter blur is skipped.",
    "keyboard": [
      {
        "key": "Tab",
        "description": "Navigate between accordion item triggers."
      },
      {
        "key": "Enter / Space",
        "description": "Expand or collapse the focused accordion item."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Product FAQ sections and documentation feature breakdowns",
      "Feature highlights where dramatic typography reveals enhance storytelling",
      "Settings drawers and expandable guidance cards in developer consoles"
    ],
    "bestPractices": [
      "Keep descriptions under 250 characters for optimal staggered reveal pacing",
      "Use closeOnClickOutside for interactive drawers in compact layouts",
      "Reduced-motion fallbacks apply automatically from the OS setting; no configuration needed"
    ]
  },
  "props": [
    {
      "name": "items",
      "type": "AccordionItem[]",
      "description": "Array of items containing title and description strings to render in the accordion.",
      "required": true
    },
    {
      "name": "defaultIndex",
      "type": "number | null",
      "defaultValue": "1",
      "description": "Index of the item that should be open by default on mount."
    },
    {
      "name": "collapsible",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether clicking an active item closes it."
    },
    {
      "name": "letterDelay",
      "type": "number",
      "defaultValue": "auto",
      "description": "Custom stagger delay in seconds between consecutive characters."
    },
    {
      "name": "stiffness",
      "type": "number",
      "defaultValue": "180",
      "description": "Spring stiffness coefficient for the vertical letter translation."
    },
    {
      "name": "damping",
      "type": "number",
      "defaultValue": "22",
      "description": "Spring damping coefficient to control bouncing on letter arrival."
    },
    {
      "name": "mass",
      "type": "number",
      "defaultValue": "0.6",
      "description": "Spring mass for the per-letter blur reveal."
    },
    {
      "name": "blurAmount",
      "type": "number",
      "defaultValue": "8",
      "description": "Initial blur filter radius in pixels before dissolving into sharpness."
    },
    {
      "name": "closeOnClickOutside",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether clicking outside the accordion automatically closes any open items."
    },
    {
      "name": "onToggle",
      "type": "(index: number | null) => void",
      "description": "Called with the index of the newly opened item, or null when every item is closed."
    }
  ],
  "usage": "import { Accordion } from \"@/components/ui/accordion\";\n\nconst items = [\n  { title: \"What is DevClub UI?\", description: \"Animated React components you copy into your project.\" },\n  { title: \"Does it support dark mode?\", description: \"Yes, every component ships light and dark styles.\" },\n];\n\nexport function AccordionDemo() {\n  return <Accordion items={items} defaultIndex={0} />;\n}",
  "files": [
    {
      "name": "accordion.tsx",
      "path": "registry/ui/accordion.tsx",
      "code": "\"use client\";\n\nimport React, {\n  useState,\n  useRef,\n  useEffect,\n  useMemo,\n  forwardRef,\n  useId,\n  useImperativeHandle,\n} from \"react\";\nimport { motion, AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AccordionItem {\n  title: string;\n  description: string;\n}\n\nexport interface BlurTextProps {\n  text?: string;\n  delay?: number;\n  startDelay?: number;\n  className?: string;\n  animateBy?: \"words\" | \"letters\";\n  direction?: \"top\" | \"bottom\";\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  blurAmount?: number;\n  onAnimationComplete?: () => void;\n}\n\nexport const BlurText: React.FC<BlurTextProps> = ({\n  text = \"\",\n  delay,\n  startDelay = 0.05,\n  className = \"\",\n  animateBy = \"letters\",\n  direction = \"bottom\",\n  stiffness = 180,\n  damping = 22,\n  mass = 0.6,\n  blurAmount = 8,\n  onAnimationComplete,\n}) => {\n  const prefersReducedMotion = useReducedMotion();\n  const words = useMemo(() => text.split(\" \"), [text]);\n\n  const totalChars = useMemo(() => text.length, [text]);\n  const effectiveDelay = useMemo(() => {\n    if (delay !== undefined) return delay;\n    if (animateBy === \"words\") return 0.05;\n    return Math.max(0.007, Math.min(0.016, 1.1 / Math.max(totalChars, 1)));\n  }, [delay, animateBy, totalChars]);\n\n  let charCounter = 0;\n\n  if (prefersReducedMotion) {\n    return <p className={cn(\"leading-relaxed\", className)}>{text}</p>;\n  }\n\n  return (\n    <p\n      aria-label={text}\n      className={cn(\n        \"flex flex-wrap items-baseline leading-relaxed select-none\",\n        className,\n      )}\n    >\n      {words.map((word, wordIndex) => {\n        if (animateBy === \"words\") {\n          const currentWordIndex = wordIndex;\n          const isLast = currentWordIndex === words.length - 1;\n          const wordDelay = startDelay + currentWordIndex * effectiveDelay;\n\n          return (\n            <React.Fragment key={wordIndex}>\n              <motion.span\n                aria-hidden=\"true\"\n                initial={{\n                  opacity: 0,\n                  filter: `blur(${blurAmount}px)`,\n                  y: direction === \"top\" ? -8 : 8,\n                }}\n                animate={{\n                  opacity: 1,\n                  filter: \"blur(0px)\",\n                  y: 0,\n                }}\n                transition={{\n                  opacity: {\n                    duration: 0.32,\n                    ease: [0.22, 1, 0.36, 1],\n                    delay: wordDelay,\n                  },\n                  filter: {\n                    duration: 0.36,\n                    ease: [0.22, 1, 0.36, 1],\n                    delay: wordDelay,\n                  },\n                  y: {\n                    type: \"spring\",\n                    stiffness,\n                    damping,\n                    mass,\n                    delay: wordDelay,\n                  },\n                }}\n                onAnimationComplete={isLast ? onAnimationComplete : undefined}\n                style={{\n                  display: \"inline-block\",\n                  willChange: \"transform, filter, opacity\",\n                }}\n              >\n                {word}\n              </motion.span>\n              {wordIndex < words.length - 1 && (\n                <span aria-hidden=\"true\" className=\"inline-block\">\n                  &nbsp;\n                </span>\n              )}\n            </React.Fragment>\n          );\n        }\n\n        const letters = word.split(\"\");\n\n        return (\n          <React.Fragment key={wordIndex}>\n            <span aria-hidden=\"true\" className=\"inline-block whitespace-nowrap\">\n              {letters.map((char, letterIndex) => {\n                const globalIndex = charCounter++;\n                const isLast =\n                  globalIndex === totalChars - 1 ||\n                  (wordIndex === words.length - 1 &&\n                    letterIndex === letters.length - 1);\n                const letterAnimationDelay =\n                  startDelay + globalIndex * effectiveDelay;\n\n                return (\n                  <motion.span\n                    key={letterIndex}\n                    initial={{\n                      opacity: 0,\n                      filter: `blur(${blurAmount}px)`,\n                      y: direction === \"top\" ? -8 : 8,\n                    }}\n                    animate={{\n                      opacity: 1,\n                      filter: \"blur(0px)\",\n                      y: 0,\n                    }}\n                    transition={{\n                      opacity: {\n                        duration: 0.32,\n                        ease: [0.22, 1, 0.36, 1],\n                        delay: letterAnimationDelay,\n                      },\n                      filter: {\n                        duration: 0.36,\n                        ease: [0.22, 1, 0.36, 1],\n                        delay: letterAnimationDelay,\n                      },\n                      y: {\n                        type: \"spring\",\n                        stiffness,\n                        damping,\n                        mass,\n                        delay: letterAnimationDelay,\n                      },\n                    }}\n                    onAnimationComplete={\n                      isLast ? onAnimationComplete : undefined\n                    }\n                    style={{\n                      display: \"inline-block\",\n                      willChange: \"transform, filter, opacity\",\n                    }}\n                  >\n                    {char}\n                  </motion.span>\n                );\n              })}\n            </span>\n            {wordIndex < words.length - 1 && (\n              <span aria-hidden=\"true\" className=\"inline-block\">\n                &nbsp;\n              </span>\n            )}\n          </React.Fragment>\n        );\n      })}\n    </p>\n  );\n};\n\nexport interface AccordionProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onToggle\"\n> {\n  items: AccordionItem[];\n  defaultIndex?: number | null;\n  collapsible?: boolean;\n  letterDelay?: number;\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  blurAmount?: number;\n  closeOnClickOutside?: boolean;\n  onToggle?: (index: number | null) => void;\n}\n\nexport const Accordion = forwardRef<HTMLDivElement, AccordionProps>(\n  (\n    {\n      items,\n      className,\n      defaultIndex = 1,\n      collapsible = true,\n      letterDelay,\n      stiffness = 180,\n      damping = 22,\n      mass = 0.6,\n      blurAmount = 8,\n      closeOnClickOutside = true,\n      onToggle,\n      ...props\n    },\n    ref,\n  ) => {\n    const [activeIndex, setActiveIndex] = useState<number | null>(defaultIndex);\n    const innerRef = useRef<HTMLDivElement>(null);\n    const prefersReducedMotion = useReducedMotion();\n    const baseId = useId();\n\n    useImperativeHandle(ref, () => innerRef.current as HTMLDivElement);\n\n    useEffect(() => {\n      if (!closeOnClickOutside) return;\n\n      const handleClickOutside = (event: MouseEvent) => {\n        if (\n          innerRef.current &&\n          !innerRef.current.contains(event.target as Node)\n        ) {\n          setActiveIndex(null);\n          onToggle?.(null);\n        }\n      };\n\n      document.addEventListener(\"mousedown\", handleClickOutside);\n      return () =>\n        document.removeEventListener(\"mousedown\", handleClickOutside);\n    }, [closeOnClickOutside, onToggle]);\n\n    const toggleAccordion = (index: number) => {\n      const nextIndex =\n        activeIndex === index ? (collapsible ? null : index) : index;\n      setActiveIndex(nextIndex);\n      onToggle?.(nextIndex);\n    };\n\n    return (\n      <div\n        ref={innerRef}\n        className={cn(\"w-full select-none\", className)}\n        {...props}\n      >\n        <div className=\"mx-auto w-full max-w-lg rounded-2xl bg-linear-to-b from-[#fdf7f9] to-[#fcebf2] p-1.5 border border-black/5 dark:from-black dark:to-[#0c0c0e]/80 dark:border-white/8\">\n          <div className=\"space-y-1.5\">\n            {items.map((item, index) => {\n              const isOpen = activeIndex === index;\n              const triggerId = `${baseId}-trigger-${index}`;\n              const panelId = `${baseId}-panel-${index}`;\n\n              return (\n                <div\n                  key={index}\n                  className={cn(\n                    \"overflow-hidden rounded-xl border transition-all duration-300\",\n                    isOpen\n                      ? \"border-black/10 bg-white shadow-sm dark:border-white/20 dark:bg-[#121217] dark:shadow-[0_12px_32px_rgba(0,0,0,0.4)]\"\n                      : \"border-transparent bg-white shadow-xs hover:border-black/5 dark:border-white/8 dark:bg-[#0c0c0e]/90 dark:hover:border-white/15 dark:hover:bg-[#101014]\",\n                  )}\n                >\n                  <button\n                    type=\"button\"\n                    id={triggerId}\n                    aria-expanded={isOpen}\n                    aria-controls={isOpen ? panelId : undefined}\n                    className=\"group flex w-full cursor-pointer items-center justify-between px-5 py-4 text-left text-base font-medium transition-colors text-gray-800 hover:text-gray-950 dark:text-zinc-200 dark:hover:text-white\"\n                    onClick={() => toggleAccordion(index)}\n                  >\n                    <span\n                      className={cn(\n                        \"transition-colors duration-200\",\n                        isOpen\n                          ? \"text-gray-950 dark:text-white\"\n                          : \"text-gray-800 dark:text-zinc-200\",\n                      )}\n                    >\n                      {item.title}\n                    </span>\n                    <div className=\"relative flex items-center justify-center w-5 h-5 text-gray-400 transition-colors dark:text-zinc-400 group-hover:text-gray-600 dark:group-hover:text-zinc-200\">\n                      <motion.span\n                        className=\"absolute h-[1.5px] w-3 bg-current rounded-full\"\n                        animate={{ rotate: isOpen ? 180 : 0 }}\n                        transition={\n                          prefersReducedMotion\n                            ? { duration: 0 }\n                            : { type: \"spring\", stiffness: 320, damping: 24 }\n                        }\n                      />\n                      <motion.span\n                        className=\"absolute w-[1.5px] h-3 bg-current rounded-full\"\n                        animate={{\n                          scaleY: isOpen ? 0 : 1,\n                          opacity: isOpen ? 0 : 1,\n                          rotate: isOpen ? 90 : 0,\n                        }}\n                        transition={\n                          prefersReducedMotion\n                            ? { duration: 0 }\n                            : { type: \"spring\", stiffness: 320, damping: 24 }\n                        }\n                      />\n                    </div>\n                  </button>\n                  <AnimatePresence initial={false}>\n                    {isOpen && (\n                      <motion.div\n                        initial={{ height: 0, opacity: 0 }}\n                        animate={{\n                          height: \"auto\",\n                          opacity: 1,\n                          transition: prefersReducedMotion\n                            ? { duration: 0 }\n                            : {\n                                height: {\n                                  duration: 0.38,\n                                  ease: [0.16, 1, 0.3, 1],\n                                },\n                                opacity: {\n                                  duration: 0.25,\n                                  ease: \"easeOut\",\n                                },\n                              },\n                        }}\n                        exit={{\n                          height: 0,\n                          opacity: 0,\n                          transition: prefersReducedMotion\n                            ? { duration: 0 }\n                            : {\n                                height: {\n                                  duration: 0.28,\n                                  ease: [0.16, 1, 0.3, 1],\n                                },\n                                opacity: {\n                                  duration: 0.18,\n                                  ease: \"easeIn\",\n                                },\n                              },\n                        }}\n                        id={panelId}\n                        role=\"region\"\n                        aria-labelledby={triggerId}\n                        className=\"overflow-hidden\"\n                      >\n                        <div\n                          className=\"cursor-pointer px-5 pb-4 text-sm leading-relaxed text-gray-600 dark:text-zinc-400 font-light\"\n                          onClick={() => toggleAccordion(index)}\n                        >\n                          <BlurText\n                            text={item.description}\n                            animateBy=\"letters\"\n                            delay={letterDelay}\n                            stiffness={stiffness}\n                            damping={damping}\n                            mass={mass}\n                            blurAmount={blurAmount}\n                          />\n                        </div>\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    );\n  },\n);\n\nAccordion.displayName = \"Accordion\";\n\nexport default Accordion;\n",
      "content": "\"use client\";\n\nimport React, {\n  useState,\n  useRef,\n  useEffect,\n  useMemo,\n  forwardRef,\n  useId,\n  useImperativeHandle,\n} from \"react\";\nimport { motion, AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AccordionItem {\n  title: string;\n  description: string;\n}\n\nexport interface BlurTextProps {\n  text?: string;\n  delay?: number;\n  startDelay?: number;\n  className?: string;\n  animateBy?: \"words\" | \"letters\";\n  direction?: \"top\" | \"bottom\";\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  blurAmount?: number;\n  onAnimationComplete?: () => void;\n}\n\nexport const BlurText: React.FC<BlurTextProps> = ({\n  text = \"\",\n  delay,\n  startDelay = 0.05,\n  className = \"\",\n  animateBy = \"letters\",\n  direction = \"bottom\",\n  stiffness = 180,\n  damping = 22,\n  mass = 0.6,\n  blurAmount = 8,\n  onAnimationComplete,\n}) => {\n  const prefersReducedMotion = useReducedMotion();\n  const words = useMemo(() => text.split(\" \"), [text]);\n\n  const totalChars = useMemo(() => text.length, [text]);\n  const effectiveDelay = useMemo(() => {\n    if (delay !== undefined) return delay;\n    if (animateBy === \"words\") return 0.05;\n    return Math.max(0.007, Math.min(0.016, 1.1 / Math.max(totalChars, 1)));\n  }, [delay, animateBy, totalChars]);\n\n  let charCounter = 0;\n\n  if (prefersReducedMotion) {\n    return <p className={cn(\"leading-relaxed\", className)}>{text}</p>;\n  }\n\n  return (\n    <p\n      aria-label={text}\n      className={cn(\n        \"flex flex-wrap items-baseline leading-relaxed select-none\",\n        className,\n      )}\n    >\n      {words.map((word, wordIndex) => {\n        if (animateBy === \"words\") {\n          const currentWordIndex = wordIndex;\n          const isLast = currentWordIndex === words.length - 1;\n          const wordDelay = startDelay + currentWordIndex * effectiveDelay;\n\n          return (\n            <React.Fragment key={wordIndex}>\n              <motion.span\n                aria-hidden=\"true\"\n                initial={{\n                  opacity: 0,\n                  filter: `blur(${blurAmount}px)`,\n                  y: direction === \"top\" ? -8 : 8,\n                }}\n                animate={{\n                  opacity: 1,\n                  filter: \"blur(0px)\",\n                  y: 0,\n                }}\n                transition={{\n                  opacity: {\n                    duration: 0.32,\n                    ease: [0.22, 1, 0.36, 1],\n                    delay: wordDelay,\n                  },\n                  filter: {\n                    duration: 0.36,\n                    ease: [0.22, 1, 0.36, 1],\n                    delay: wordDelay,\n                  },\n                  y: {\n                    type: \"spring\",\n                    stiffness,\n                    damping,\n                    mass,\n                    delay: wordDelay,\n                  },\n                }}\n                onAnimationComplete={isLast ? onAnimationComplete : undefined}\n                style={{\n                  display: \"inline-block\",\n                  willChange: \"transform, filter, opacity\",\n                }}\n              >\n                {word}\n              </motion.span>\n              {wordIndex < words.length - 1 && (\n                <span aria-hidden=\"true\" className=\"inline-block\">\n                  &nbsp;\n                </span>\n              )}\n            </React.Fragment>\n          );\n        }\n\n        const letters = word.split(\"\");\n\n        return (\n          <React.Fragment key={wordIndex}>\n            <span aria-hidden=\"true\" className=\"inline-block whitespace-nowrap\">\n              {letters.map((char, letterIndex) => {\n                const globalIndex = charCounter++;\n                const isLast =\n                  globalIndex === totalChars - 1 ||\n                  (wordIndex === words.length - 1 &&\n                    letterIndex === letters.length - 1);\n                const letterAnimationDelay =\n                  startDelay + globalIndex * effectiveDelay;\n\n                return (\n                  <motion.span\n                    key={letterIndex}\n                    initial={{\n                      opacity: 0,\n                      filter: `blur(${blurAmount}px)`,\n                      y: direction === \"top\" ? -8 : 8,\n                    }}\n                    animate={{\n                      opacity: 1,\n                      filter: \"blur(0px)\",\n                      y: 0,\n                    }}\n                    transition={{\n                      opacity: {\n                        duration: 0.32,\n                        ease: [0.22, 1, 0.36, 1],\n                        delay: letterAnimationDelay,\n                      },\n                      filter: {\n                        duration: 0.36,\n                        ease: [0.22, 1, 0.36, 1],\n                        delay: letterAnimationDelay,\n                      },\n                      y: {\n                        type: \"spring\",\n                        stiffness,\n                        damping,\n                        mass,\n                        delay: letterAnimationDelay,\n                      },\n                    }}\n                    onAnimationComplete={\n                      isLast ? onAnimationComplete : undefined\n                    }\n                    style={{\n                      display: \"inline-block\",\n                      willChange: \"transform, filter, opacity\",\n                    }}\n                  >\n                    {char}\n                  </motion.span>\n                );\n              })}\n            </span>\n            {wordIndex < words.length - 1 && (\n              <span aria-hidden=\"true\" className=\"inline-block\">\n                &nbsp;\n              </span>\n            )}\n          </React.Fragment>\n        );\n      })}\n    </p>\n  );\n};\n\nexport interface AccordionProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onToggle\"\n> {\n  items: AccordionItem[];\n  defaultIndex?: number | null;\n  collapsible?: boolean;\n  letterDelay?: number;\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  blurAmount?: number;\n  closeOnClickOutside?: boolean;\n  onToggle?: (index: number | null) => void;\n}\n\nexport const Accordion = forwardRef<HTMLDivElement, AccordionProps>(\n  (\n    {\n      items,\n      className,\n      defaultIndex = 1,\n      collapsible = true,\n      letterDelay,\n      stiffness = 180,\n      damping = 22,\n      mass = 0.6,\n      blurAmount = 8,\n      closeOnClickOutside = true,\n      onToggle,\n      ...props\n    },\n    ref,\n  ) => {\n    const [activeIndex, setActiveIndex] = useState<number | null>(defaultIndex);\n    const innerRef = useRef<HTMLDivElement>(null);\n    const prefersReducedMotion = useReducedMotion();\n    const baseId = useId();\n\n    useImperativeHandle(ref, () => innerRef.current as HTMLDivElement);\n\n    useEffect(() => {\n      if (!closeOnClickOutside) return;\n\n      const handleClickOutside = (event: MouseEvent) => {\n        if (\n          innerRef.current &&\n          !innerRef.current.contains(event.target as Node)\n        ) {\n          setActiveIndex(null);\n          onToggle?.(null);\n        }\n      };\n\n      document.addEventListener(\"mousedown\", handleClickOutside);\n      return () =>\n        document.removeEventListener(\"mousedown\", handleClickOutside);\n    }, [closeOnClickOutside, onToggle]);\n\n    const toggleAccordion = (index: number) => {\n      const nextIndex =\n        activeIndex === index ? (collapsible ? null : index) : index;\n      setActiveIndex(nextIndex);\n      onToggle?.(nextIndex);\n    };\n\n    return (\n      <div\n        ref={innerRef}\n        className={cn(\"w-full select-none\", className)}\n        {...props}\n      >\n        <div className=\"mx-auto w-full max-w-lg rounded-2xl bg-linear-to-b from-[#fdf7f9] to-[#fcebf2] p-1.5 border border-black/5 dark:from-black dark:to-[#0c0c0e]/80 dark:border-white/8\">\n          <div className=\"space-y-1.5\">\n            {items.map((item, index) => {\n              const isOpen = activeIndex === index;\n              const triggerId = `${baseId}-trigger-${index}`;\n              const panelId = `${baseId}-panel-${index}`;\n\n              return (\n                <div\n                  key={index}\n                  className={cn(\n                    \"overflow-hidden rounded-xl border transition-all duration-300\",\n                    isOpen\n                      ? \"border-black/10 bg-white shadow-sm dark:border-white/20 dark:bg-[#121217] dark:shadow-[0_12px_32px_rgba(0,0,0,0.4)]\"\n                      : \"border-transparent bg-white shadow-xs hover:border-black/5 dark:border-white/8 dark:bg-[#0c0c0e]/90 dark:hover:border-white/15 dark:hover:bg-[#101014]\",\n                  )}\n                >\n                  <button\n                    type=\"button\"\n                    id={triggerId}\n                    aria-expanded={isOpen}\n                    aria-controls={isOpen ? panelId : undefined}\n                    className=\"group flex w-full cursor-pointer items-center justify-between px-5 py-4 text-left text-base font-medium transition-colors text-gray-800 hover:text-gray-950 dark:text-zinc-200 dark:hover:text-white\"\n                    onClick={() => toggleAccordion(index)}\n                  >\n                    <span\n                      className={cn(\n                        \"transition-colors duration-200\",\n                        isOpen\n                          ? \"text-gray-950 dark:text-white\"\n                          : \"text-gray-800 dark:text-zinc-200\",\n                      )}\n                    >\n                      {item.title}\n                    </span>\n                    <div className=\"relative flex items-center justify-center w-5 h-5 text-gray-400 transition-colors dark:text-zinc-400 group-hover:text-gray-600 dark:group-hover:text-zinc-200\">\n                      <motion.span\n                        className=\"absolute h-[1.5px] w-3 bg-current rounded-full\"\n                        animate={{ rotate: isOpen ? 180 : 0 }}\n                        transition={\n                          prefersReducedMotion\n                            ? { duration: 0 }\n                            : { type: \"spring\", stiffness: 320, damping: 24 }\n                        }\n                      />\n                      <motion.span\n                        className=\"absolute w-[1.5px] h-3 bg-current rounded-full\"\n                        animate={{\n                          scaleY: isOpen ? 0 : 1,\n                          opacity: isOpen ? 0 : 1,\n                          rotate: isOpen ? 90 : 0,\n                        }}\n                        transition={\n                          prefersReducedMotion\n                            ? { duration: 0 }\n                            : { type: \"spring\", stiffness: 320, damping: 24 }\n                        }\n                      />\n                    </div>\n                  </button>\n                  <AnimatePresence initial={false}>\n                    {isOpen && (\n                      <motion.div\n                        initial={{ height: 0, opacity: 0 }}\n                        animate={{\n                          height: \"auto\",\n                          opacity: 1,\n                          transition: prefersReducedMotion\n                            ? { duration: 0 }\n                            : {\n                                height: {\n                                  duration: 0.38,\n                                  ease: [0.16, 1, 0.3, 1],\n                                },\n                                opacity: {\n                                  duration: 0.25,\n                                  ease: \"easeOut\",\n                                },\n                              },\n                        }}\n                        exit={{\n                          height: 0,\n                          opacity: 0,\n                          transition: prefersReducedMotion\n                            ? { duration: 0 }\n                            : {\n                                height: {\n                                  duration: 0.28,\n                                  ease: [0.16, 1, 0.3, 1],\n                                },\n                                opacity: {\n                                  duration: 0.18,\n                                  ease: \"easeIn\",\n                                },\n                              },\n                        }}\n                        id={panelId}\n                        role=\"region\"\n                        aria-labelledby={triggerId}\n                        className=\"overflow-hidden\"\n                      >\n                        <div\n                          className=\"cursor-pointer px-5 pb-4 text-sm leading-relaxed text-gray-600 dark:text-zinc-400 font-light\"\n                          onClick={() => toggleAccordion(index)}\n                        >\n                          <BlurText\n                            text={item.description}\n                            animateBy=\"letters\"\n                            delay={letterDelay}\n                            stiffness={stiffness}\n                            damping={damping}\n                            mass={mass}\n                            blurAmount={blurAmount}\n                          />\n                        </div>\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    );\n  },\n);\n\nAccordion.displayName = \"Accordion\";\n\nexport default Accordion;\n",
      "type": "registry:ui",
      "target": "@ui/accordion.tsx"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "Blur Reveal Accordion"
}
