{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spine-accordion",
  "type": "registry:ui",
  "title": "Spine Accordion",
  "description": "A shelf of work you open one piece at a time. Closed, every project is a hairline spine with its name set bottom-to-top along the edge; hover, tap or focus one and it unfurls into a framed picture while the rest close back. The layout is one animated flex-grow over a shared basis, so the shelf fits four pieces or twenty without measuring anything, and stacks into rows when its container is narrow.",
  "files": [
    {
      "path": "registry/crafterui/ui/spine-accordion.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// A shelf of work you open one piece at a time.\n//\n// Closed, every project is a spine: a hairline column with its name set\n// bottom-to-top along the edge, the way a title runs down the side of a book on\n// a shelf. Point at one and it unfurls - the spine slides open into a framed\n// picture and the art fades up inside it while everything else closes back to a\n// hairline.\n//\n// The whole layout is one animated length: flex-basis. A closed panel asks for\n// one spine's thickness, the open one asks for the entire shelf, and flex\n// settles the overshoot by shrinking everybody in proportion - which, with the\n// spines floored at their own thickness, hands the open panel every pixel the\n// others do not need. The shelf therefore fits four pieces or twenty with no\n// measurement and no per-count tuning, and the same rule stacks it into rows in\n// a narrow container, because flex-basis follows the main axis whichever way it\n// happens to point.\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface SpineAccordionItem {\n  /** Project name, set along the spine. Also names the panel for a11y. */\n  title: string\n  /** Art revealed when the panel opens. Any src an <img> takes. */\n  image: string\n  /** Sits at the far end of the spine, e.g. a year range. @default undefined */\n  meta?: string\n}\n\nexport interface SpineAccordionProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"section\">,\n    \"children\" | \"onChange\"\n  > {\n  /** Pieces, in shelf order. */\n  items: SpineAccordionItem[]\n  /** Open panel when controlled. Leave unset for internal state. @default undefined */\n  index?: number\n  /** Open panel on mount when uncontrolled. @default 0 */\n  defaultIndex?: number\n  /** Fires on every change, from pointer, tap or keyboard focus. @default undefined */\n  onIndexChange?: (index: number) => void\n  /**\n   * Thickness of a closed spine - its width in a row, its height in a stack.\n   * Any CSS length; also sets the gutter the open panel's art is inset by.\n   * @default \"4rem\"\n   */\n  spine?: string\n}\n\nexport function SpineAccordion({\n  items,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  spine = \"4rem\",\n  className,\n  ...props\n}: SpineAccordionProps) {\n  const [internal, setInternal] = React.useState(defaultIndex)\n  const open = index ?? internal\n\n  const select = React.useCallback(\n    (next: number) => {\n      if (index === undefined) setInternal(next)\n      onIndexChange?.(next)\n    },\n    [index, onIndexChange]\n  )\n\n  return (\n    // Sized by its container, not the viewport: @container means the shelf\n    // stacks and re-types itself off its own width, so it behaves the same in a\n    // page column as it does full bleed.\n    <section\n      className={cn(\n        \"@container bg-background text-foreground relative h-full min-h-[24rem] w-full overflow-hidden select-none\",\n        className\n      )}\n      style={{ \"--spine\": spine } as React.CSSProperties}\n      {...props}\n    >\n      <div className=\"flex h-full w-full flex-col overflow-y-auto @3xl:flex-row @3xl:overflow-y-visible\">\n        {items.map((item, i) => {\n          const isOpen = i === open\n          return (\n            <button\n              key={item.title}\n              type=\"button\"\n              aria-expanded={isOpen}\n              onClick={() => select(i)}\n              onFocus={() => select(i)}\n              onPointerEnter={(event) => {\n                // Hover opens - but only under a mouse. On touch the enter\n                // event arrives with the tap that is already selecting, and a\n                // stylus fires it from a hover the reader never meant.\n                if (event.pointerType === \"mouse\") select(i)\n              }}\n              style={{ flexBasis: isOpen ? \"100%\" : \"var(--spine)\" }}\n              className={cn(\n                \"border-border relative min-h-[var(--spine)] min-w-[var(--spine)] shrink cursor-pointer border-b text-left outline-none transition-[flex-basis] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] last:border-0\",\n                \"focus-visible:outline-foreground focus-visible:-outline-offset-2 focus-visible:outline-2\",\n                \"motion-reduce:transition-none\",\n                \"@3xl:border-r @3xl:border-b-0\"\n              )}\n            >\n              {/* Mounted in every panel and merely transparent while closed, so\n                  unfurling cross-fades a picture the browser already decoded\n                  rather than starting a fetch the reader waits on. */}\n              <span\n                className={cn(\n                  \"bg-muted absolute inset-x-3 top-[var(--spine)] bottom-3 block transition-opacity duration-700 motion-reduce:transition-none\",\n                  \"@3xl:inset-y-3 @3xl:top-3 @3xl:right-3 @3xl:left-[var(--spine)]\",\n                  isOpen ? \"opacity-100\" : \"opacity-0\"\n                )}\n              >\n                <img\n                  src={item.image}\n                  alt=\"\"\n                  draggable={false}\n                  className=\"size-full object-cover\"\n                />\n              </span>\n\n              {/* The spine. Stretched to the panel's full height so the title\n                  can sit at one end and the year at the other, then turned on\n                  its side by writing-mode - which keeps the text in normal flow\n                  and costs no height measurement, where a rotate would. */}\n              <span\n                className={cn(\n                  \"absolute inset-x-4 top-4 flex items-start justify-between gap-3 overflow-hidden transition-colors duration-500\",\n                  \"@3xl:inset-x-auto @3xl:inset-y-4 @3xl:left-4 @3xl:flex-col-reverse @3xl:gap-0\",\n                  \"motion-reduce:transition-none\",\n                  isOpen ? \"text-foreground\" : \"text-muted-foreground\"\n                )}\n              >\n                <span className=\"truncate text-base font-medium tracking-tight @3xl:overflow-visible @3xl:rotate-180 @3xl:text-lg @3xl:[writing-mode:vertical-rl] @5xl:text-xl\">\n                  {item.title}\n                </span>\n                {item.meta ? (\n                  <span\n                    className={cn(\n                      \"shrink-0 text-xs tabular-nums transition-opacity duration-500 @3xl:rotate-180 @3xl:text-sm @3xl:[writing-mode:vertical-rl] motion-reduce:transition-none\",\n                      isOpen ? \"opacity-100\" : \"opacity-0\"\n                    )}\n                  >\n                    {item.meta}\n                  </span>\n                ) : null}\n              </span>\n            </button>\n          )\n        })}\n      </div>\n    </section>\n  )\n}\n"
    }
  ]
}
