{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "apple-accordion",
  "title": "Apple Accordion",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Accordion whose items morph from pills into cards, inspired by Apple’s product pages.",
  "dependencies": [
    "@base-ui/react",
    "lucide-react"
  ],
  "files": [
    {
      "path": "src/registry/components/apple-accordion/apple-accordion.tsx",
      "content": "\"use client\"\n\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  use,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\"\nimport type { ComponentProps, RefObject } from \"react\"\nimport { mergeProps } from \"@base-ui/react/merge-props\"\nimport { useRender } from \"@base-ui/react/use-render\"\nimport {\n  ChevronDownIcon,\n  ChevronUpIcon,\n  CirclePlusIcon,\n  XIcon,\n} from \"lucide-react\"\nimport { flushSync } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Size = { width: number; height: number }\n\nfunction useElementSize(ref: RefObject<HTMLElement | null>) {\n  const [size, setSize] = useState<Size | null>(null)\n\n  useLayoutEffect(() => {\n    const element = ref.current\n    if (!element) return\n\n    const observer = new ResizeObserver(([entry]) => {\n      const [box] = entry.borderBoxSize\n      setSize({ width: box.inlineSize, height: box.blockSize })\n    })\n    observer.observe(element)\n\n    return () => observer.disconnect()\n  }, [ref])\n\n  return size\n}\n\nconst getTriggerId = (rootId: string, index: number) =>\n  `${rootId}-${index}-trigger`\nconst getContentId = (rootId: string, index: number) =>\n  `${rootId}-${index}-content`\n\ntype AppleAccordionContextValue = {\n  id: string\n  value: string | null\n  index: number\n  count: number\n  setValues: (update: (values: string[]) => string[]) => void\n  open: (value: string) => void\n  close: () => void\n  step: (offset: -1 | 1) => void\n}\n\nconst AppleAccordionContext = createContext<AppleAccordionContextValue | null>(\n  null\n)\n\nfunction useAppleAccordion() {\n  const context = use(AppleAccordionContext)\n  if (!context) {\n    throw new Error(\n      \"AppleAccordion parts must be used within AppleAccordionRoot\"\n    )\n  }\n  return context\n}\n\ntype AppleAccordionState = {\n  /** Whether any item is expanded. */\n  open: boolean\n}\n\ntype AppleAccordionRootProps = Omit<\n  useRender.ComponentProps<\"div\", AppleAccordionState>,\n  \"defaultValue\"\n> & {\n  /** Value of the expanded item, or `null` when every item is collapsed. */\n  value?: string | null\n  /**\n   * Item expanded on mount when uncontrolled.\n   * @defaultValue null\n   */\n  defaultValue?: string | null\n  onValueChange?: (value: string | null) => void\n}\n\nfunction AppleAccordionRoot({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  className,\n  render,\n  ref,\n  ...props\n}: AppleAccordionRootProps) {\n  const id = useId()\n  const [uncontrolledValue, setUncontrolledValue] = useState<string | null>(\n    defaultValue\n  )\n  const value = valueProp === undefined ? uncontrolledValue : valueProp\n  const [values, setValues] = useState<string[]>([])\n  const index = value === null ? -1 : values.indexOf(value)\n\n  // Focus follows the card through flushSync: the trigger turns inert and the\n  // content stops being inert in the same commit, so the move can't miss.\n  const setValue = (next: string | null) => {\n    flushSync(() => {\n      if (valueProp === undefined) setUncontrolledValue(next)\n      onValueChange?.(next)\n    })\n  }\n\n  const open = (next: string) => {\n    setValue(next)\n    document.getElementById(getContentId(id, values.indexOf(next)))?.focus()\n  }\n\n  const close = () => {\n    if (value === null) return\n    setValue(null)\n    document.getElementById(getTriggerId(id, index))?.focus()\n  }\n\n  // Paddles keep focus, so pressing one again keeps stepping.\n  const step = (offset: -1 | 1) => {\n    const next = values[index + offset]\n    if (index !== -1 && next !== undefined) setValue(next)\n  }\n\n  const defaultProps: ComponentProps<\"div\"> & { \"data-slot\": string } = {\n    \"data-slot\": \"apple-accordion\",\n    className: cn(\n      \"group/apple-accordion @container relative isolate w-full\",\n      className\n    ),\n    onKeyDown: (event) => {\n      if (event.key !== \"Escape\" || value === null) return\n      event.preventDefault()\n      close()\n    },\n  }\n\n  const element = useRender({\n    defaultTagName: \"div\",\n    render,\n    ref,\n    state: { open: value !== null },\n    props: mergeProps<\"div\">(defaultProps, props),\n  })\n\n  return (\n    <AppleAccordionContext\n      value={{\n        id,\n        value,\n        index,\n        count: values.length,\n        setValues,\n        open,\n        close,\n        step,\n      }}\n    >\n      {element}\n    </AppleAccordionContext>\n  )\n}\n\nconst AppleAccordionListIndexContext = createContext<number | null>(null)\n\nfunction isSameList(a: string[], b: string[]) {\n  return a.length === b.length && a.every((value, i) => value === b[i])\n}\n\nfunction AppleAccordionList({\n  className,\n  children,\n  render,\n  ref,\n  ...props\n}: useRender.ComponentProps<\"ul\">) {\n  const { setValues } = useAppleAccordion()\n\n  const items = Children.toArray(children).filter(\n    isValidElement<{ value: string }>\n  )\n  const values = items.map((item) => item.props.value)\n\n  // The paddles need the order before any of them is pressed, and only the\n  // list sees every item.\n  useLayoutEffect(() => {\n    setValues((previous) => (isSameList(previous, values) ? previous : values))\n  })\n\n  const defaultProps: ComponentProps<\"ul\"> & { \"data-slot\": string } = {\n    \"data-slot\": \"apple-accordion-list\",\n    role: \"list\",\n    className: cn(\"flex flex-col items-start gap-3\", className),\n    children: items.map((item, index) => (\n      <AppleAccordionListIndexContext key={item.key} value={index}>\n        {item}\n      </AppleAccordionListIndexContext>\n    )),\n  }\n\n  return useRender({\n    defaultTagName: \"ul\",\n    render,\n    ref,\n    props: mergeProps<\"ul\">(defaultProps, props),\n  })\n}\n\ntype AppleAccordionItemContextValue = {\n  value: string\n  isOpen: boolean\n  triggerId: string\n  contentId: string\n  triggerRef: RefObject<HTMLButtonElement | null>\n  contentRef: RefObject<HTMLDivElement | null>\n}\n\nconst AppleAccordionItemContext =\n  createContext<AppleAccordionItemContextValue | null>(null)\n\nfunction useAppleAccordionItem() {\n  const context = use(AppleAccordionItemContext)\n  if (!context) {\n    throw new Error(\n      \"AppleAccordionTrigger and AppleAccordionContent must be used within AppleAccordionItem\"\n    )\n  }\n  return context\n}\n\ntype AppleAccordionItemState = {\n  /** Whether this item is expanded into a card. */\n  open: boolean\n}\n\ntype AppleAccordionItemProps = Omit<\n  useRender.ComponentProps<\"li\", AppleAccordionItemState>,\n  \"value\"\n> & {\n  /** Unique value that identifies the item. */\n  value: string\n}\n\nfunction AppleAccordionItem({\n  value,\n  className,\n  render,\n  ref,\n  ...props\n}: AppleAccordionItemProps) {\n  const { id, value: openValue } = useAppleAccordion()\n  const index = use(AppleAccordionListIndexContext)\n  if (index === null) {\n    throw new Error(\n      \"AppleAccordionItem must be a direct child of AppleAccordionList\"\n    )\n  }\n\n  const isOpen = openValue === value\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const contentRef = useRef<HTMLDivElement>(null)\n  const triggerSize = useElementSize(triggerRef)\n  const contentSize = useElementSize(contentRef)\n  // Both layers stay laid out, so the pill can morph between their measured\n  // boxes. Until measured, the trigger in flow sizes the pill on its own.\n  const size = isOpen ? contentSize : triggerSize\n\n  const defaultProps: ComponentProps<\"li\"> & { \"data-slot\": string } = {\n    \"data-slot\": \"apple-accordion-item\",\n    style: size ?? undefined,\n    className: cn(\n      \"group/apple-accordion-item relative overflow-hidden rounded-[1.75rem] bg-muted/70 shadow-md ring ring-foreground/10 backdrop-blur-lg not-data-open:hover:bg-muted/90\",\n      // The size morphs on screen; the hover color is a quick, small change.\n      \"transition-[width,height,background-color] duration-[450ms,450ms,150ms] ease-[cubic-bezier(0.645,0.045,0.355,1),cubic-bezier(0.645,0.045,0.355,1),ease]\",\n      \"motion-reduce:transition-[background-color] motion-reduce:duration-150 motion-reduce:ease-[ease]\",\n      className\n    ),\n  }\n\n  const element = useRender({\n    defaultTagName: \"li\",\n    render,\n    ref,\n    state: { open: isOpen },\n    props: mergeProps<\"li\">(defaultProps, props),\n  })\n\n  return (\n    <AppleAccordionItemContext\n      value={{\n        value,\n        isOpen,\n        triggerId: getTriggerId(id, index),\n        contentId: getContentId(id, index),\n        triggerRef,\n        contentRef,\n      }}\n    >\n      {element}\n    </AppleAccordionItemContext>\n  )\n}\n\nfunction AppleAccordionTrigger({\n  className,\n  render,\n  ref,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  const { open } = useAppleAccordion()\n  const { value, isOpen, triggerId, contentId, triggerRef } =\n    useAppleAccordionItem()\n\n  const defaultProps: ComponentProps<\"button\"> & { \"data-slot\": string } = {\n    \"data-slot\": \"apple-accordion-trigger\",\n    type: \"button\",\n    id: triggerId,\n    \"aria-expanded\": isOpen,\n    \"aria-controls\": contentId,\n    // The card covers it while open, so it leaves the tab order too.\n    inert: isOpen,\n    className: cn(\n      \"flex h-14 w-max origin-left items-center gap-3 rounded-[inherit] ps-3.5 pe-8 text-base font-medium whitespace-nowrap text-foreground\",\n      \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\",\n      // Gets out of the way at once, then returns only after the card has\n      // mostly shrunk back, so the label never spills over the card.\n      \"transition-[opacity,scale] delay-200 duration-300 ease-out\",\n      \"group-data-open/apple-accordion-item:scale-90 group-data-open/apple-accordion-item:opacity-0 group-data-open/apple-accordion-item:delay-0 group-data-open/apple-accordion-item:duration-150\",\n      \"motion-reduce:group-data-open/apple-accordion-item:scale-100\",\n      className\n    ),\n    onClick: () => open(value),\n  }\n\n  return useRender({\n    defaultTagName: \"button\",\n    render,\n    ref: [ref ?? null, triggerRef],\n    props: mergeProps<\"button\">(defaultProps, props),\n  })\n}\n\nfunction AppleAccordionTriggerIcon({\n  className,\n  children,\n  ...props\n}: ComponentProps<\"span\">) {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"apple-accordion-trigger-icon\"\n      className={cn(\n        \"flex size-7 shrink-0 items-center justify-center [&_svg]:size-full\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? <CirclePlusIcon strokeWidth={1.5} />}\n    </span>\n  )\n}\n\nfunction AppleAccordionContent({\n  className,\n  render,\n  ref,\n  ...props\n}: useRender.ComponentProps<\"div\">) {\n  const { isOpen, triggerId, contentId, contentRef } = useAppleAccordionItem()\n\n  const defaultProps: ComponentProps<\"div\"> & { \"data-slot\": string } = {\n    \"data-slot\": \"apple-accordion-content\",\n    id: contentId,\n    role: \"group\",\n    \"aria-labelledby\": triggerId,\n    tabIndex: -1,\n    inert: !isOpen,\n    className: cn(\n      // Sized by its own text, not by the pill it grows out of.\n      \"absolute inset-s-0 top-0 flex w-max max-w-[min(--spacing(100),100cqw-(--spacing(20)))] flex-col gap-3 rounded-[inherit] p-7 text-base text-pretty text-foreground\",\n      \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\",\n      // Leaves fast, and arrives once the card has room for it.\n      \"translate-y-4 opacity-0 transition-[opacity,translate] duration-150 ease-out\",\n      \"group-data-open/apple-accordion-item:translate-y-0 group-data-open/apple-accordion-item:opacity-100 group-data-open/apple-accordion-item:delay-150 group-data-open/apple-accordion-item:duration-400\",\n      \"motion-reduce:translate-y-0\",\n      className\n    ),\n  }\n\n  return useRender({\n    defaultTagName: \"div\",\n    render,\n    ref: [ref ?? null, contentRef],\n    props: mergeProps<\"div\">(defaultProps, props),\n  })\n}\n\n// Shared by the paddles and the close button: a 36px disc inside a 44px\n// target, shown only while a card is open.\nfunction IconButton({\n  className,\n  render,\n  ref,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  const { value } = useAppleAccordion()\n  const isOpen = value !== null\n\n  const defaultProps: ComponentProps<\"button\"> = {\n    type: \"button\",\n    inert: !isOpen,\n    className: cn(\n      \"relative flex size-9 shrink-0 items-center justify-center rounded-full bg-muted/70 text-foreground shadow-md ring ring-foreground/10 backdrop-blur-lg after:absolute after:-inset-1 hover:bg-muted/90 aria-disabled:hover:bg-muted/70 [&_svg]:size-5\",\n      \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n      \"scale-90 opacity-0 transition-[opacity,scale,background-color] duration-150 ease-out\",\n      \"group-data-open/apple-accordion:scale-100 group-data-open/apple-accordion:opacity-100 group-data-open/apple-accordion:duration-[250ms,150ms,150ms] group-data-open/apple-accordion:aria-disabled:opacity-40\",\n      // Press feedback like Button, deeper because a 36px disc hides 2%. It\n      // stacks on the open variant to outrank its scale-100.\n      \"group-data-open/apple-accordion:not-aria-disabled:motion-safe:active:scale-95\",\n      \"motion-reduce:scale-100\",\n      className\n    ),\n  }\n\n  return useRender({\n    defaultTagName: \"button\",\n    render,\n    ref,\n    props: mergeProps<\"button\">(defaultProps, props),\n  })\n}\n\nfunction AppleAccordionPrevious({\n  children,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  const { index, step } = useAppleAccordion()\n  // aria-disabled instead of disabled: a disabled button drops focus to the\n  // body when the last step reaches the end.\n  const isDisabled = index <= 0\n\n  return (\n    <IconButton\n      {...mergeProps<\"button\">(\n        {\n          \"aria-label\": \"Previous\",\n          \"aria-disabled\": isDisabled || undefined,\n          onClick: () => step(-1),\n        },\n        props\n      )}\n      data-slot=\"apple-accordion-previous\"\n    >\n      {children ?? <ChevronUpIcon strokeWidth={2.5} />}\n    </IconButton>\n  )\n}\n\nfunction AppleAccordionNext({\n  children,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  const { index, count, step } = useAppleAccordion()\n  const isDisabled = index === -1 || index >= count - 1\n\n  return (\n    <IconButton\n      {...mergeProps<\"button\">(\n        {\n          \"aria-label\": \"Next\",\n          \"aria-disabled\": isDisabled || undefined,\n          onClick: () => step(1),\n        },\n        props\n      )}\n      data-slot=\"apple-accordion-next\"\n    >\n      {children ?? <ChevronDownIcon strokeWidth={2.5} />}\n    </IconButton>\n  )\n}\n\nfunction AppleAccordionClose({\n  children,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  const { close } = useAppleAccordion()\n\n  return (\n    <IconButton\n      {...mergeProps<\"button\">(\n        { \"aria-label\": \"Close\", onClick: close },\n        props\n      )}\n      data-slot=\"apple-accordion-close\"\n    >\n      {children ?? <XIcon strokeWidth={2.5} />}\n    </IconButton>\n  )\n}\n\nexport {\n  AppleAccordionClose,\n  AppleAccordionContent,\n  AppleAccordionItem,\n  AppleAccordionList,\n  AppleAccordionNext,\n  AppleAccordionPrevious,\n  AppleAccordionRoot,\n  AppleAccordionTrigger,\n  AppleAccordionTriggerIcon,\n}\nexport type {\n  AppleAccordionItemProps,\n  AppleAccordionItemState,\n  AppleAccordionRootProps,\n  AppleAccordionState,\n}\n",
      "type": "registry:component",
      "target": "@components/apple-accordion.tsx"
    }
  ],
  "docs": "https://chanhdai.com/components/apple-accordion",
  "categories": [
    "marketing"
  ],
  "type": "registry:component"
}