{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "accordion",
  "type": "registry:ui",
  "title": "Accordion",
  "description": "Sections that open in place, split by hairlines: 56px triggers in headings with a chevron that turns half a turn, one section open at a time or several. The height snaps, the content fades in and drops a little, and the rows below move by transform, so the page never jumps.",
  "dependencies": [
    "@radix-ui/react-accordion",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/accordion.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/accordion 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Accordion: sections that open in place, such as questions and answers, groups of settings or a long spec\n * (components.md, Accordion; decisions 16 and 17). Built on Radix Accordion for the roles, the keyboard and the\n * open state; everything visual is ours.\n *\n * - Look: rows split by 1px --litt-line hairlines, one above the first and one below the last. Each trigger is a\n *   full-width button at least 56px tall, its label at the body size in --litt-fg, left-aligned, with a 16px chevron\n *   in --litt-fg-subtle at the end that brightens to --litt-fg on hover (instant tier, color only). The content sits under\n *   its trigger in --litt-fg-muted at the reading measure (--litt-measure), with space-6 below it. No card, no fills, no accent.\n * - Behavior: one section open at a time by default (type \"single\", and the open one can close), or several\n *   (type \"multiple\"). Each trigger sits in a heading, h3 unless headingLevel says otherwise, and carries\n *   aria-expanded and aria-controls (Radix).\n * - Motion, all on the responsive tier. The content's height is never animated: it snaps open. The content\n *   fades in and drops a quarter of the travel token, the chevron turns half a turn, and the rows below move to\n *   their new place by transform (Motion layout=\"position\", so no text stretches). Closing takes the content out\n *   of the flow at once (popLayout) and fades it on the exit token while the rows below rise.\n * - No jump. The root moves by position too, so if something above shifts the whole accordion in the same\n *   render, the hairline on top and the rows move as one block. (Not Motion's layoutRoot: it cuts the rows'\n *   spring short whenever the accordion re-renders mid-flight, and AnimatePresence re-renders it when the\n *   content's fade ends.) While a section closes, the accordion keeps its old height until the content has\n *   faded, so what sits under it (a card's edge, the next section) never covers the rows still rising. Then the\n *   height snaps, as it snapped open.\n * - Reduced motion: the hooks return zero and the duration tokens are zero, so everything snaps.\n * - Touch: there is no hover, so on a coarse pointer a pressed trigger fills at fg 6% (instant tier, color only).\n * - Focus by modality: :focus-visible only, the 2px ring at a 2px offset around the trigger.\n * - Flat: no dividers; each trigger is a pill-radius row that turns grey on hover, the open item becomes a grey card, and the chevron sits on a round grey key that turns.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { ChevronDownIcon } from \"./icons\";\nimport { useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\n/* -------------------------------------------------------------------------- */\n/* Context                                                                    */\n/* -------------------------------------------------------------------------- */\n\ntype HeadingLevel = 2 | 3 | 4 | 5 | 6;\n\ntype RootCtx = {\n  /** The values of the open sections. */\n  open: string[];\n  headingLevel: HeadingLevel;\n  /** Keep the accordion at its last settled height while a section's content fades out. */\n  hold: () => void;\n  /** Let it go again, once every hold is released. */\n  release: () => void;\n};\nconst RootContext = createContext<RootCtx | null>(null);\nfunction useRoot(part: string) {\n  const ctx = useContext(RootContext);\n  if (!ctx) throw new Error(`${part} must be used inside <Accordion>`);\n  return ctx;\n}\n\nconst ItemContext = createContext<{ open: boolean } | null>(null);\nfunction useItem(part: string) {\n  const ctx = useContext(ItemContext);\n  if (!ctx) throw new Error(`${part} must be used inside <AccordionItem>`);\n  return ctx;\n}\n\nconst toList = (v: string | string[] | undefined): string[] => (Array.isArray(v) ? v : v ? [v] : []);\n\n/* -------------------------------------------------------------------------- */\n/* Accordion                                                                  */\n/* -------------------------------------------------------------------------- */\n\n/** What the open state holds: one value for type \"single\" (\"\" when every section is closed), a list for \"multiple\". */\nexport type AccordionValue<T extends \"single\" | \"multiple\"> = T extends \"multiple\" ? string[] : string;\n\nexport type AccordionProps<T extends \"single\" | \"multiple\" = \"single\"> = {\n  /** \"single\" (the default) keeps one section open at a time, and opening another closes it. \"multiple\" lets several stay open. */\n  type?: T;\n  /** With type \"single\", whether the open section closes when its trigger is pressed again. */\n  collapsible?: boolean;\n  /** The open section's value, or a list of them with type \"multiple\", when you control it. Pair it with onValueChange. */\n  value?: AccordionValue<T>;\n  /** What starts open when you do not control it: a section's value, or a list of them with type \"multiple\". */\n  defaultValue?: AccordionValue<T>;\n  /** Called with the new open value each time a section opens or closes: a string (\"\" when all are closed), or a list with type \"multiple\". */\n  onValueChange?: (value: AccordionValue<T>) => void;\n  /** The heading level each trigger sits in. Pick the one that fits the page's outline; the size never changes. */\n  headingLevel?: HeadingLevel;\n  /** Extra classes for the root, such as a width or a margin. */\n  className?: string;\n  /** The AccordionItem for each section. */\n  children: ReactNode;\n};\n\n/** The root. It holds which sections are open, draws the hairline above the first row and keeps the rows still. */\nexport function Accordion<T extends \"single\" | \"multiple\" = \"single\">({\n  type,\n  collapsible = true,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  headingLevel = 3,\n  className = \"\",\n  children,\n}: AccordionProps<T>) {\n  const multiple = type === \"multiple\";\n  const enter = useTransition(\"responsive\");\n  const [inner, setInner] = useState<string[]>(() => toList(defaultValue));\n  const open = valueProp === undefined ? inner : toList(valueProp);\n\n  const change = (next: string[]) => {\n    setInner(next);\n    onValueChange?.((multiple ? next : (next[0] ?? \"\")) as AccordionValue<T>);\n  };\n\n  /* The hold. A ResizeObserver keeps the height the accordion last settled at. When a section closes, its\n     content leaves the flow in the same commit, before the browser lays out again, so in that commit the\n     observer still has the old height: a min-height of that keeps the box still until the content has faded. */\n  const rootRef = useRef<HTMLDivElement>(null);\n  const settled = useRef(0);\n  const holds = useRef(0);\n  useLayoutEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const read = () => {\n      if (holds.current === 0) settled.current = el.getBoundingClientRect().height;\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  const hold = useCallback(() => {\n    const el = rootRef.current;\n    if (el && holds.current++ === 0) el.style.minHeight = `${settled.current}px`;\n  }, []);\n  const release = useCallback(() => {\n    if (holds.current === 0) return;\n    holds.current -= 1;\n    const el = rootRef.current;\n    if (el && holds.current === 0) {\n      el.style.minHeight = \"\";\n      settled.current = el.getBoundingClientRect().height;\n    }\n  }, []);\n\n  const ctx = useMemo<RootCtx>(() => ({ open, headingLevel, hold, release }), [open, headingLevel, hold, release]);\n\n  const body = (\n    <motion.div ref={rootRef} layout=\"position\" transition={enter} className={\"border-t border-litt-line in-[[data-material=flat]]:border-t-0 in-[[data-material=flat]]:grid in-[[data-material=flat]]:gap-1 \" + className}>\n      {children}\n    </motion.div>\n  );\n\n  return (\n    <RootContext.Provider value={ctx}>\n      <LayoutGroup>\n        {multiple ? (\n          <AccordionPrimitive.Root type=\"multiple\" value={open} onValueChange={change} {...asChildProps}>\n            {body}\n          </AccordionPrimitive.Root>\n        ) : (\n          <AccordionPrimitive.Root\n            type=\"single\"\n            collapsible={collapsible}\n            value={open[0] ?? \"\"}\n            onValueChange={(v) => change(v ? [v] : [])}\n            {...asChildProps}\n          >\n            {body}\n          </AccordionPrimitive.Root>\n        )}\n      </LayoutGroup>\n    </RootContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* AccordionItem                                                              */\n/* -------------------------------------------------------------------------- */\n\nexport type AccordionItemProps = {\n  /** A value unique in this accordion. It is what value, defaultValue and onValueChange speak in. */\n  value: string;\n  /** Greys the trigger out and keeps it from opening. Arrow keys skip it. */\n  disabled?: boolean;\n  /** Extra classes for the row. */\n  className?: string;\n  /** An AccordionTrigger, then an AccordionContent. */\n  children: ReactNode;\n};\n\n/** One section: the trigger, the content under it, and the hairline below. It moves by transform when a row above it opens or closes. */\nexport function AccordionItem({ value, disabled = false, className = \"\", children }: AccordionItemProps) {\n  const { open } = useRoot(\"AccordionItem\");\n  const enter = useTransition(\"responsive\");\n  const isOpen = open.includes(value);\n  const item = useMemo(() => ({ open: isOpen }), [isOpen]);\n\n  return (\n    <ItemContext.Provider value={item}>\n      <AccordionPrimitive.Item value={value} disabled={disabled} {...asChildProps}>\n        {/* relative: the content that leaves is placed against the row (popLayout). */}\n        <motion.div layout=\"position\" transition={enter} className={\"relative border-b border-litt-line in-[[data-material=flat]]:border-b-0 in-[[data-material=flat]]:rounded-litt-surface in-[[data-material=flat]]:data-[state=open]:bg-litt-surface-3 transition-colors duration-(--litt-dur-instant) ease-litt \" + className}>\n          {children}\n        </motion.div>\n      </AccordionPrimitive.Item>\n    </ItemContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* AccordionTrigger                                                           */\n/* -------------------------------------------------------------------------- */\n\nconst trigger =\n  \"group/trigger flex min-h-litt-control-lg w-full cursor-pointer items-center gap-4 rounded-litt-control py-4 text-left text-litt-body text-litt-fg font-litt-title \" +\n  \"in-[[data-material=flat]]:rounded-litt-surface in-[[data-material=flat]]:px-4 in-[[data-material=flat]]:py-3 in-[[data-material=flat]]:hover:bg-litt-surface-3 \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-[background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"enabled:pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n\n/* Flat: the chevron sits on a round grey key that turns with it; on an open card the key lifts to surface-1. */\nconst KEY =\n  \"in-[[data-material=flat]]:size-8 in-[[data-material=flat]]:place-items-center in-[[data-material=flat]]:rounded-litt-pill in-[[data-material=flat]]:bg-litt-surface-3 \" +\n  \"in-[[data-material=flat]]:group-data-[state=open]/trigger:bg-litt-surface-1\";\n\nexport type AccordionTriggerProps = {\n  /** The section's name, at the body size. It is the button's accessible name, so keep it short and plain. */\n  children: ReactNode;\n  /** Extra classes for the button. */\n  className?: string;\n};\n\n/** The row you press: a full-width button inside a heading, with the label and a chevron that turns half a turn when it opens. */\nexport function AccordionTrigger({ children, className = \"\" }: AccordionTriggerProps) {\n  const { headingLevel } = useRoot(\"AccordionTrigger\");\n  useItem(\"AccordionTrigger\");\n  const Heading = `h${headingLevel}` as const;\n\n  return (\n    <AccordionPrimitive.Header {...asChildProps}>\n      <Heading className=\"flex\">\n        <AccordionPrimitive.Trigger className={trigger + \" \" + className}>\n          <span className=\"min-w-0 flex-1\">{children}</span>\n          {/* Two layers: the turn is responsive, the color instant, and one element cannot pair two tiers. */}\n          <span\n            aria-hidden=\"true\"\n            className={\"grid shrink-0 transition-[rotate] duration-(--litt-dur-responsive) ease-litt-responsive group-data-[state=open]/trigger:rotate-180 \" + KEY}\n          >\n            <ChevronDownIcon className=\"text-litt-fg-subtle transition-colors duration-(--litt-dur-instant) ease-litt group-hover/trigger:text-litt-fg group-disabled/trigger:text-litt-fg-disabled\" />\n          </span>\n        </AccordionPrimitive.Trigger>\n      </Heading>\n    </AccordionPrimitive.Header>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* AccordionContent                                                           */\n/* -------------------------------------------------------------------------- */\n\nexport type AccordionContentProps = {\n  /** What the section holds: text, a list, a few settings. Paragraphs space themselves, so give them a gap or margin. */\n  children: ReactNode;\n  /** Extra classes, such as text-litt-small for a denser section, or max-w-none for settings rows that should reach the chevron. */\n  className?: string;\n};\n\n/** The section's content: a region named by its trigger, mounted only while open. Its height snaps; it fades in and drops a little. */\nexport function AccordionContent({ children, className = \"\" }: AccordionContentProps) {\n  const { hold, release } = useRoot(\"AccordionContent\");\n  const { open } = useItem(\"AccordionContent\");\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n\n  /* A section that starts open appears without a fade. Not through AnimatePresence initial={false}: that reaches\n     every motion component inside the content and would swallow their own entrances. */\n  const mounted = useRef(false);\n  useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  /* Hold the accordion's height from the commit that closes this section until its content has faded. A section\n     that opens again mid-fade cancels the exit, so it lets go then instead. */\n  const held = useRef(false);\n  const was = useRef(open);\n  const letGo = useCallback(() => {\n    if (!held.current) return;\n    held.current = false;\n    release();\n  }, [release]);\n  useLayoutEffect(() => {\n    if (was.current && !open && !held.current) {\n      held.current = true;\n      hold();\n    }\n    if (!was.current && open) letGo();\n    was.current = open;\n  }, [open, hold, letGo]);\n  useEffect(() => letGo, [letGo]);\n\n  return (\n    <AnimatePresence mode=\"popLayout\" onExitComplete={letGo}>\n      {open && (\n        <AccordionPrimitive.Content key=\"content\" forceMount {...asChildProps}>\n          <motion.div\n            initial={mounted.current ? { opacity: 0, y: -travel / 4 } : false}\n            animate={{ opacity: 1, y: 0, transition: enter }}\n            exit={{ opacity: 0, transition: exit }}\n            className={\"max-w-(--litt-measure) pb-6 in-[[data-material=flat]]:px-4 in-[[data-material=flat]]:pb-5 text-litt-body text-litt-fg-muted data-[state=closed]:pointer-events-none \" + className}\n          >\n            {children}\n          </motion.div>\n        </AccordionPrimitive.Content>\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "target": "components/litt/accordion.tsx"
    }
  ],
  "docs": "Import Accordion, AccordionItem, AccordionTrigger and AccordionContent from components/litt/accordion. Give each AccordionItem a unique value and put an AccordionTrigger and an AccordionContent in it. Use type=\"multiple\" to let several stay open (the value is then a list), defaultValue or value with onValueChange for the open state, and headingLevel to fit the page's outline.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
