# Spine Accordion

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.

- Demo: https://crafterui.com/components/spine-accordion
- Install: `npx shadcn@latest add https://crafterui.com/r/spine-accordion.json`
- Installs to: `registry/crafterui/ui/spine-accordion.tsx`

## Usage

```tsx
"use client"

import {
  SpineAccordion,
  type SpineAccordionItem,
} from "@/registry/crafterui/ui/spine-accordion"

// Placeholder art served straight off the crafterui CDN so the demo works the
// moment it is installed - no assets to copy into your public/. Swap the names,
// titles and year ranges for your own index; the shelf sizes itself to however
// many pieces it is handed.
const ART = (name: string) =>
  `/art/${name}.jpg`

const SHELF: SpineAccordionItem[] = [
  { title: "Prismatic Rift", image: ART("prismatic-rift-anime"), meta: "2025—2026" },
  { title: "Ember Clouds", image: ART("black-hole-ember-clouds"), meta: "2025—2026" },
  { title: "Neon Portal", image: ART("neon-cave-portal-silhouette"), meta: "2024—2026" },
  { title: "Red Ribbon", image: ART("red-ribbon-typography"), meta: "2024—2025" },
  { title: "Celestial Light", image: ART("celestial-light-figure"), meta: "2024—2025" },
  { title: "Neon Uplight", image: ART("neon-portrait-uplight"), meta: "2023—2025" },
  { title: "Indigo Marble", image: ART("indigo-liquid-marble"), meta: "2023—2024" },
  { title: "Launch Window", image: ART("rocket-launch-gradient"), meta: "2022—2024" },
  { title: "Cosmic Wave", image: ART("astronaut-cosmic-wave"), meta: "2022—2023" },
]

export default function SpineAccordionDemo() {
  return <SpineAccordion items={SHELF} defaultIndex={7} />
}
```

## Source - `registry/crafterui/ui/spine-accordion.tsx`

```tsx
"use client"

// A shelf of work you open one piece at a time.
//
// Closed, every project is a spine: a hairline column with its name set
// bottom-to-top along the edge, the way a title runs down the side of a book on
// a shelf. Point at one and it unfurls - the spine slides open into a framed
// picture and the art fades up inside it while everything else closes back to a
// hairline.
//
// The whole layout is one animated length: flex-basis. A closed panel asks for
// one spine's thickness, the open one asks for the entire shelf, and flex
// settles the overshoot by shrinking everybody in proportion - which, with the
// spines floored at their own thickness, hands the open panel every pixel the
// others do not need. The shelf therefore fits four pieces or twenty with no
// measurement and no per-count tuning, and the same rule stacks it into rows in
// a narrow container, because flex-basis follows the main axis whichever way it
// happens to point.
import * as React from "react"

import { cn } from "@/lib/utils"

export interface SpineAccordionItem {
  /** Project name, set along the spine. Also names the panel for a11y. */
  title: string
  /** Art revealed when the panel opens. Any src an <img> takes. */
  image: string
  /** Sits at the far end of the spine, e.g. a year range. @default undefined */
  meta?: string
}

export interface SpineAccordionProps
  extends Omit<
    React.ComponentPropsWithoutRef<"section">,
    "children" | "onChange"
  > {
  /** Pieces, in shelf order. */
  items: SpineAccordionItem[]
  /** Open panel when controlled. Leave unset for internal state. @default undefined */
  index?: number
  /** Open panel on mount when uncontrolled. @default 0 */
  defaultIndex?: number
  /** Fires on every change, from pointer, tap or keyboard focus. @default undefined */
  onIndexChange?: (index: number) => void
  /**
   * Thickness of a closed spine - its width in a row, its height in a stack.
   * Any CSS length; also sets the gutter the open panel's art is inset by.
   * @default "4rem"
   */
  spine?: string
}

export function SpineAccordion({
  items,
  index,
  defaultIndex = 0,
  onIndexChange,
  spine = "4rem",
  className,
  ...props
}: SpineAccordionProps) {
  const [internal, setInternal] = React.useState(defaultIndex)
  const open = index ?? internal

  const select = React.useCallback(
    (next: number) => {
      if (index === undefined) setInternal(next)
      onIndexChange?.(next)
    },
    [index, onIndexChange]
  )

  return (
    // Sized by its container, not the viewport: @container means the shelf
    // stacks and re-types itself off its own width, so it behaves the same in a
    // page column as it does full bleed.
    <section
      className={cn(
        "@container bg-background text-foreground relative h-full min-h-[24rem] w-full overflow-hidden select-none",
        className
      )}
      style={{ "--spine": spine } as React.CSSProperties}
      {...props}
    >
      <div className="flex h-full w-full flex-col overflow-y-auto @3xl:flex-row @3xl:overflow-y-visible">
        {items.map((item, i) => {
          const isOpen = i === open
          return (
            <button
              key={item.title}
              type="button"
              aria-expanded={isOpen}
              onClick={() => select(i)}
              onFocus={() => select(i)}
              onPointerEnter={(event) => {
                // Hover opens - but only under a mouse. On touch the enter
                // event arrives with the tap that is already selecting, and a
                // stylus fires it from a hover the reader never meant.
                if (event.pointerType === "mouse") select(i)
              }}
              style={{ flexBasis: isOpen ? "100%" : "var(--spine)" }}
              className={cn(
                "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",
                "focus-visible:outline-foreground focus-visible:-outline-offset-2 focus-visible:outline-2",
                "motion-reduce:transition-none",
                "@3xl:border-r @3xl:border-b-0"
              )}
            >
              {/* Mounted in every panel and merely transparent while closed, so
                  unfurling cross-fades a picture the browser already decoded
                  rather than starting a fetch the reader waits on. */}
              <span
                className={cn(
                  "bg-muted absolute inset-x-3 top-[var(--spine)] bottom-3 block transition-opacity duration-700 motion-reduce:transition-none",
                  "@3xl:inset-y-3 @3xl:top-3 @3xl:right-3 @3xl:left-[var(--spine)]",
                  isOpen ? "opacity-100" : "opacity-0"
                )}
              >
                <img
                  src={item.image}
                  alt=""
                  draggable={false}
                  className="size-full object-cover"
                />
              </span>

              {/* The spine. Stretched to the panel's full height so the title
                  can sit at one end and the year at the other, then turned on
                  its side by writing-mode - which keeps the text in normal flow
                  and costs no height measurement, where a rotate would. */}
              <span
                className={cn(
                  "absolute inset-x-4 top-4 flex items-start justify-between gap-3 overflow-hidden transition-colors duration-500",
                  "@3xl:inset-x-auto @3xl:inset-y-4 @3xl:left-4 @3xl:flex-col-reverse @3xl:gap-0",
                  "motion-reduce:transition-none",
                  isOpen ? "text-foreground" : "text-muted-foreground"
                )}
              >
                <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">
                  {item.title}
                </span>
                {item.meta ? (
                  <span
                    className={cn(
                      "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",
                      isOpen ? "opacity-100" : "opacity-0"
                    )}
                  >
                    {item.meta}
                  </span>
                ) : null}
              </span>
            </button>
          )
        })}
      </div>
    </section>
  )
}
```
