{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stretch-index",
  "type": "registry:ui",
  "title": "Stretch Index",
  "description": "A list of rows that open in place. The row you're on grows taller, its tab widens into a photo, the title slides over to make room and the details rise out from under it, while the rows below glide down.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/stretch-index.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionStyle,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * STRETCH INDEX STORYBOARD\n *\n *  rest     a list of rows: number, title, meta. A thin tab sits\n *           just before each title\n *  hover    the tab warms to the row's accent and a dimmed row\n *           brightens as soon as the pointer is on it. The row\n *           opens once the pointer slows or settles, so sweeping\n *           down the list doesn't set it rippling\n *  open     on one spring: the tab widens into a picture frame\n *           and the photo fades in, the title slides over to make\n *           room, the row grows and pushes the rows below down,\n *           and the details rise out from under the title. Text\n *           is laid out at its open width all along, so nothing\n *           rewraps while it moves\n *  switch   the old row closes as the new one opens. The heights\n *           trade off, so the row under the pointer stays under it\n *  leave    the open row stays open, or closes with collapseOnLeave\n *  touch    the first tap opens a row, the second follows its link\n *  keys     ↑/↓, Home and End move between rows. In hover lists a\n *           row opens as focus reaches it; in click lists Enter\n *           opens it. Enter on an open row follows its link, Esc\n *           closes. A row opened from the keyboard or a tap keeps\n *           itself in view as it grows\n *  reduced  no springs or zoom: rows switch at once\n * ───────────────────────────────────────────────────────── */\n\nconst STRETCH = {\n  open: { type: \"spring\", visualDuration: 0.5, bounce: 0 },\n  dwell: 90, // ms the pointer rests on a row before it opens\n  slow: 0.35, // px/ms; a pointer moving slower than this opens a row at once\n  leave: 160, // ms outside the list before collapseOnLeave closes the row\n  tab: 3, // px width of a closed row's tab\n  gap: 16, // px between the tab or photo and the text\n  zoom: 0.06, // a photo starts this much closer in and eases back as it opens\n} as const;\n\n// Geometry follows the row's open progress, --stretch-p (0–1), set per frame by motion.\nconst FRAME_WIDTH = `calc(${STRETCH.tab}px + (var(--stretch-w) - ${STRETCH.tab}px) * var(--stretch-p))`;\nconst FRAME_HEIGHT = \"calc(1lh + (var(--stretch-h) - 1lh) * var(--stretch-p))\";\n\ntype Reveal = \"pointer\" | \"scroll\";\n\ntype List = {\n  active: number | null;\n  openOn: \"hover\" | \"click\";\n  numbered: boolean;\n  dimmed: boolean;\n  reduced: boolean;\n  select: (index: number | null, reveal?: Reveal) => void;\n  /** Whether this row was opened from the keyboard or a tap, and should scroll fully into view. */\n  revealsRow: (index: number) => boolean;\n  /** The kind of pointer that last pressed the list. */\n  lastPointer: () => string;\n  onKeyDown: (event: React.KeyboardEvent, from: number) => void;\n};\n\nconst ListContext = React.createContext<List | null>(null);\nconst IndexContext = React.createContext(-1);\n\nfunction useList() {\n  const list = React.useContext(ListContext);\n  if (!list) throw new Error(\"<StretchIndexItem> must be rendered inside <StretchIndex>.\");\n  return list;\n}\n\n/* ── StretchIndex ───────────────────────────────────────── */\n\nexport interface StretchIndexProps extends React.HTMLAttributes<HTMLUListElement> {\n  /** The open row, or null for none. Leave unset to let the list manage it. */\n  index?: number | null;\n  /** The row that starts open. */\n  defaultIndex?: number | null;\n  /** Called when a row opens, or with null when the open row closes. */\n  onIndexChange?: (index: number | null) => void;\n  /** \"hover\" opens the row under the pointer; \"click\" waits for a click or tap, and a second click closes it. */\n  openOn?: \"hover\" | \"click\";\n  /** Close the open row when the pointer or keyboard focus leaves the list. Otherwise it stays open. */\n  collapseOnLeave?: boolean;\n  /** Fade the other rows back while one is open and the pointer or focus is in the list. */\n  dim?: boolean;\n  /** Show a 01, 02, … column before the titles. */\n  numbered?: boolean;\n  /** Width of an open row's photo, as a CSS length. The default scales with the list. */\n  imageWidth?: string;\n  /** Width-to-height ratio of an open row's photo. */\n  imageAspect?: number;\n}\n\n/**\n * A list of rows that open in place. The row you're on grows taller, its tab\n * widens into a photo, and the rows below glide down to make room.\n */\nexport function StretchIndex({\n  index,\n  defaultIndex = null,\n  onIndexChange,\n  openOn = \"hover\",\n  collapseOnLeave = false,\n  dim = true,\n  numbered = true,\n  imageWidth = \"clamp(5rem, 24cqi, 11rem)\",\n  imageAspect = 4 / 3,\n  className,\n  style,\n  children,\n  onPointerMove,\n  onPointerDown,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: StretchIndexProps) {\n  const items = React.Children.toArray(children).filter(React.isValidElement);\n  const count = items.length;\n  const [own, setOwn] = React.useState<number | null>(defaultIndex);\n  const current = index !== undefined ? index : own;\n  const active = current != null && current >= 0 && current < count ? current : null;\n  const reduced = useReducedMotion() ?? false;\n\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const activeRef = React.useRef(active);\n  const scrollRef = React.useRef<number | null>(null);\n  const latest = React.useRef({ index, onIndexChange, count });\n  React.useLayoutEffect(() => {\n    activeRef.current = active;\n    latest.current = { index, onIndexChange, count };\n  });\n\n  const select = React.useCallback((next: number | null, reveal: Reveal = \"pointer\") => {\n    const { index, onIndexChange, count } = latest.current;\n    if (next !== null && (next < 0 || next >= count)) return;\n    if (next === activeRef.current) return;\n    activeRef.current = next;\n    scrollRef.current = reveal === \"scroll\" ? next : null;\n    if (index === undefined) setOwn(next);\n    onIndexChange?.(next);\n  }, []);\n\n  const revealsRow = React.useCallback((row: number) => scrollRef.current === row, []);\n\n  /* Pointer: hover intent for mice; taps are handled by each row's click. */\n  const pointer = React.useRef({\n    x: NaN,\n    y: NaN,\n    t: 0,\n    speed: Infinity,\n    timer: 0,\n    kind: \"mouse\",\n  });\n  const [inside, setInside] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  React.useEffect(() => () => window.clearTimeout(pointer.current.timer), []);\n\n  const lastPointer = React.useCallback(() => pointer.current.kind, []);\n\n  const rowAt = (x: number, y: number) => {\n    const hit = document.elementFromPoint(x, y)?.closest<HTMLElement>(\"[data-stretch-index]\");\n    return hit && listRef.current?.contains(hit) ? Number(hit.dataset.stretchIndex) : -1;\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLUListElement>) => {\n    onPointerMove?.(event);\n    if (openOn !== \"hover\" || event.pointerType !== \"mouse\") return;\n    const p = pointer.current;\n    const dx = event.clientX - p.x;\n    const dy = event.clientY - p.y;\n    if (dx === 0 && dy === 0) return; // the rows moved under a still pointer\n    const dt = event.timeStamp - p.t;\n    const speed = Number.isNaN(dx) ? Infinity : dt > 0 ? Math.hypot(dx, dy) / dt : p.speed;\n    p.speed = Number.isFinite(p.speed) && Number.isFinite(speed) ? (p.speed + speed) / 2 : speed;\n    p.x = event.clientX;\n    p.y = event.clientY;\n    p.t = event.timeStamp;\n\n    window.clearTimeout(p.timer);\n    const next = rowAt(p.x, p.y);\n    if (next < 0 || next === activeRef.current) return;\n    if (p.speed < STRETCH.slow) select(next);\n    else p.timer = window.setTimeout(() => select(next), STRETCH.dwell);\n  };\n\n  const handlePointerLeave = (event: React.PointerEvent<HTMLUListElement>) => {\n    onPointerLeave?.(event);\n    const p = pointer.current;\n    window.clearTimeout(p.timer);\n    p.x = NaN;\n    p.speed = Infinity;\n    setInside(false);\n    if (collapseOnLeave && openOn === \"hover\" && event.pointerType === \"mouse\") {\n      p.timer = window.setTimeout(() => select(null), STRETCH.leave);\n    }\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent, from: number) => {\n    let to = -1;\n    if (event.key === \"ArrowDown\") to = Math.min(from + 1, count - 1);\n    else if (event.key === \"ArrowUp\") to = Math.max(from - 1, 0);\n    else if (event.key === \"Home\") to = 0;\n    else if (event.key === \"End\") to = count - 1;\n    else if (event.key === \"Escape\" && activeRef.current !== null) {\n      event.preventDefault();\n      select(null);\n      return;\n    }\n    if (to < 0) return;\n    event.preventDefault();\n    listRef.current\n      ?.querySelector<HTMLElement>(`[data-stretch-index=\"${to}\"] [data-stretch-trigger]`)\n      ?.focus();\n  };\n\n  const engaged = inside || focused;\n\n  return (\n    <ListContext.Provider\n      value={{\n        active,\n        openOn,\n        numbered,\n        dimmed: dim && engaged && active !== null,\n        reduced,\n        select,\n        revealsRow,\n        lastPointer,\n        onKeyDown,\n      }}\n    >\n      <ul\n        {...props}\n        ref={listRef}\n        role=\"list\"\n        className={cn(\"@container w-full\", className)}\n        style={\n          {\n            ...style,\n            \"--stretch-w\": imageWidth,\n            \"--stretch-h\": `calc(var(--stretch-w) / ${imageAspect})`,\n          } as React.CSSProperties\n        }\n        onPointerMove={handlePointerMove}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n          pointer.current.kind = event.pointerType;\n        }}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event);\n          if (event.pointerType !== \"mouse\") return;\n          window.clearTimeout(pointer.current.timer);\n          setInside(true);\n        }}\n        onPointerLeave={handlePointerLeave}\n        onFocus={(event) => {\n          onFocus?.(event);\n          if (event.target.matches(\":focus-visible\")) setFocused(true);\n        }}\n        onBlur={(event) => {\n          onBlur?.(event);\n          if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;\n          setFocused(false);\n          if (collapseOnLeave && openOn === \"hover\" && !inside) select(null);\n        }}\n      >\n        {items.map((item, i) => (\n          <IndexContext.Provider key={item.key ?? i} value={i}>\n            {item}\n          </IndexContext.Provider>\n        ))}\n      </ul>\n    </ListContext.Provider>\n  );\n}\n\n/* ── StretchIndexItem ───────────────────────────────────── */\n\nexport interface StretchIndexItemProps extends Omit<\n  React.LiHTMLAttributes<HTMLLIElement>,\n  \"title\"\n> {\n  /** The row's title. Keep it to a line or two. */\n  title: React.ReactNode;\n  /** Short text at the end of the row, like a year or a place. Moves under the title in narrow lists. */\n  meta?: React.ReactNode;\n  /** Replaces the automatic 01, 02, … in the number column. */\n  number?: React.ReactNode;\n  /** Makes the row a link. Touch and click-to-open lists open the row first and follow the link on the second tap. */\n  href?: string;\n  /** Photo URL. The tab widens into a frame around it when the row opens. It's decorative: the title names the row. */\n  image?: string;\n  /** Color the tab warms to, and the frame's color before the photo shows. Defaults to the text color. */\n  accent?: string;\n  /** Details that rise out from under the title when the row opens. Links in here stay clickable. */\n  children?: React.ReactNode;\n}\n\n/** One row. Render inside `StretchIndex`. */\nexport function StretchIndexItem({\n  title,\n  meta,\n  number,\n  href,\n  image,\n  accent,\n  className,\n  style,\n  children,\n  ...props\n}: StretchIndexItemProps) {\n  const list = useList();\n  const { active, openOn, numbered, dimmed, reduced, select, revealsRow } = list;\n  const index = React.useContext(IndexContext);\n  const isOpen = active === index;\n  const rowRef = React.useRef<HTMLLIElement>(null);\n  const id = React.useId();\n  const titleId = `${id}-title`;\n  const detailsId = `${id}-details`;\n\n  const open = useMotionValue(isOpen ? 1 : 0);\n  React.useEffect(() => {\n    const target = isOpen ? 1 : 0;\n    // A row opened from the keyboard or a tap keeps itself in view as it grows, frame by frame,\n    // so the page scrolls on the same spring instead of after it.\n    const follow =\n      isOpen && revealsRow(index)\n        ? () => rowRef.current?.scrollIntoView({ block: \"nearest\", behavior: \"instant\" })\n        : undefined;\n    if (reduced) {\n      open.jump(target);\n      follow?.();\n      return;\n    }\n    const controls = animate(open, target, STRETCH.open);\n    const unfollow = follow && open.on(\"change\", follow);\n    return () => {\n      controls.stop();\n      unfollow?.();\n    };\n  }, [isOpen, reduced, open, index, revealsRow]);\n\n  const rows = useTransform(open, (value) => `${value}fr`);\n  const glow = useTransform(open, [0, 0.5], [0, 1]);\n  const photo = useTransform(open, [0.15, 0.6], [0, 1]);\n  const zoom = useTransform(open, [0, 1], [1 + STRETCH.zoom, 1]);\n  const reveal = useTransform(open, [0.35, 1], [0, 1]);\n  const rise = useTransform(open, [0.35, 1], [8, 0]);\n\n  const onClick = (event: React.MouseEvent) => {\n    const keyboard = event.detail === 0;\n    const mouse = !keyboard && list.lastPointer() === \"mouse\";\n    if (isOpen) {\n      if (!href && openOn === \"click\") select(null);\n      return; // an open link row follows its link\n    }\n    // A tap, or a click in a click-to-open list, opens the row before its link is followed.\n    if (href && !(mouse && openOn === \"hover\")) event.preventDefault();\n    select(index, mouse ? \"pointer\" : \"scroll\");\n  };\n\n  const trigger = {\n    \"data-stretch-trigger\": \"\",\n    \"aria-labelledby\": titleId,\n    \"aria-expanded\": isOpen,\n    \"aria-controls\": detailsId,\n    onClick,\n    onFocus: (event: React.FocusEvent<HTMLElement>) => {\n      if (openOn === \"hover\" && event.currentTarget.matches(\":focus-visible\"))\n        select(index, \"scroll\");\n    },\n    onKeyDown: (event: React.KeyboardEvent) => list.onKeyDown(event, index),\n    className: cn(\n      \"absolute inset-0 outline-none\",\n      isOpen && !href && openOn === \"hover\" ? \"cursor-default\" : \"cursor-pointer\",\n    ),\n  };\n\n  return (\n    <li\n      {...props}\n      ref={rowRef}\n      data-stretch-index={index}\n      data-state={isOpen ? \"open\" : \"closed\"}\n      className={cn(\n        \"group/row border-b border-current/10 outline-current\",\n        \"has-[[data-stretch-trigger]:focus-visible]:rounded-sm has-[[data-stretch-trigger]:focus-visible]:outline-2 has-[[data-stretch-trigger]:focus-visible]:outline-offset-2\",\n        className,\n      )}\n      style={{ ...style, \"--stretch-accent\": accent } as React.CSSProperties}\n    >\n      <motion.div className=\"relative\" style={{ \"--stretch-p\": open } as MotionStyle}>\n        {href ? <a href={href} {...trigger} /> : <button type=\"button\" {...trigger} />}\n\n        <div\n          className={cn(\n            \"pointer-events-none relative grid items-baseline gap-x-4 py-4 transition-opacity duration-300 @lg:py-5\",\n            numbered\n              ? \"grid-cols-[2rem_minmax(0,1fr)] @lg:grid-cols-[2rem_minmax(0,1fr)_auto]\"\n              : \"grid-cols-[minmax(0,1fr)] @lg:grid-cols-[minmax(0,1fr)_auto]\",\n            dimmed && !isOpen && \"opacity-50 group-hover/row:opacity-100\",\n          )}\n        >\n          {numbered && (\n            <span className=\"font-mono text-xs tabular-nums opacity-50\">\n              {number ?? String(index + 1).padStart(2, \"0\")}\n            </span>\n          )}\n\n          <div className=\"relative min-w-0 text-xl/7 font-medium tracking-tight @lg:text-2xl/8\">\n            <div\n              aria-hidden\n              className=\"absolute top-0 left-0 overflow-hidden\"\n              style={{\n                width: FRAME_WIDTH,\n                height: FRAME_HEIGHT,\n                borderRadius: \"calc(2px + 4px * var(--stretch-p))\",\n              }}\n            >\n              <div className=\"absolute inset-0 bg-current opacity-20\" />\n              <div className=\"absolute inset-0 bg-[var(--stretch-accent,currentColor)] opacity-0 transition-opacity duration-200 group-hover/row:opacity-70\" />\n              <motion.div\n                className=\"absolute inset-0 bg-[var(--stretch-accent,currentColor)]\"\n                style={{ opacity: glow }}\n              />\n              {image && (\n                <motion.img\n                  src={image}\n                  alt=\"\"\n                  draggable={false}\n                  decoding=\"async\"\n                  className=\"absolute top-1/2 left-1/2 h-[calc(var(--stretch-h)+2px)] w-[calc(var(--stretch-w)+2px)] max-w-none -translate-1/2 object-cover\"\n                  style={{ opacity: photo, scale: zoom }}\n                />\n              )}\n            </div>\n\n            <div\n              style={{\n                marginLeft: STRETCH.tab + STRETCH.gap,\n                // Laid out at the open width, then slid back over the tab, so nothing rewraps.\n                width: `calc(100% - var(--stretch-w) - ${STRETCH.gap}px)`,\n                translate: `calc((var(--stretch-w) - ${STRETCH.tab}px) * var(--stretch-p)) 0`,\n                minHeight: FRAME_HEIGHT,\n              }}\n            >\n              <span id={titleId} className=\"block text-balance\">\n                {title}\n              </span>\n              {meta != null && (\n                <span className=\"mt-1 block text-sm font-normal tracking-normal opacity-55 @lg:hidden\">\n                  {meta}\n                </span>\n              )}\n              <motion.div className=\"grid\" style={{ gridTemplateRows: rows }}>\n                <div className=\"-mx-1 min-h-0 overflow-hidden px-1\">\n                  <motion.div\n                    id={detailsId}\n                    inert={!isOpen}\n                    className=\"pointer-events-auto relative pt-3 pb-1 text-[15px]/relaxed font-normal tracking-normal\"\n                    style={{ opacity: reveal, y: rise }}\n                  >\n                    {children}\n                  </motion.div>\n                </div>\n              </motion.div>\n            </div>\n          </div>\n\n          {meta != null && (\n            <span className=\"hidden text-sm whitespace-nowrap tabular-nums opacity-55 @lg:block\">\n              {meta}\n            </span>\n          )}\n        </div>\n      </motion.div>\n    </li>\n  );\n}\n"
    }
  ]
}