{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lift-to-open",
  "type": "registry:component",
  "title": "Lift to Open",
  "description": "A thumbnail that lifts off the grid and grows into its own detail view as one object, cover and title travelling with it, the details fading in once it lands; Escape or a click outside folds it back into its exact slot.",
  "categories": [
    "motion",
    "Surfaces"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/lift-to-open.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { XIcon } from \"lucide-react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LiftToOpenItem {\n  id: string;\n  title: string;\n  meta?: string;\n  cover: string;\n  art?: ReactNode;\n  details: ReactNode;\n}\n\nexport interface LiftToOpenProps {\n  items: LiftToOpenItem[];\n  className?: string;\n  label?: string;\n}\n\nconst frameSpring = { type: \"spring\", duration: 0.46, bounce: 0.12 } as const;\nconst resting = \"0 1px 2px rgb(0 0 0 / 0.05)\";\nconst hover = \"0 8px 20px -8px rgb(0 0 0 / 0.18), 0 1px 2px rgb(0 0 0 / 0.05)\";\nconst raised = \"0 24px 48px -16px rgb(0 0 0 / 0.28), 0 2px 6px rgb(0 0 0 / 0.06)\";\n\nfunction useCalmMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction useThemeRadius(scale: number) {\n  const probe = useRef<HTMLSpanElement>(null);\n  const [radius, setRadius] = useState(scale * 10);\n  useLayoutEffect(() => {\n    const el = probe.current;\n    if (!el) return;\n    const read = () => setRadius(el.offsetWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  const marker = (\n    <span\n      aria-hidden\n      className=\"pointer-events-none invisible absolute h-0\"\n      ref={probe}\n      style={{ width: `calc(var(--radius) * ${scale})` }}\n    />\n  );\n  return [radius, marker] as const;\n}\n\nfunction Cover({\n  item,\n  scope,\n  shared,\n  radius,\n  className,\n}: {\n  item: LiftToOpenItem;\n  scope: string;\n  shared: boolean;\n  radius: number;\n  className?: string;\n}) {\n  return (\n    <motion.div\n      className={cn(\"relative grid place-items-center overflow-hidden bg-linear-to-br text-white\", item.cover, className)}\n      layoutId={shared ? `${scope}-cover-${item.id}` : undefined}\n      style={{ borderRadius: radius }}\n      transition={frameSpring}\n    >\n      {item.art && (\n        <motion.div className=\"grid place-items-center\" layout={shared} transition={frameSpring}>\n          {item.art}\n        </motion.div>\n      )}\n    </motion.div>\n  );\n}\n\nfunction Heading({ item, scope, shared, size }: { item: LiftToOpenItem; scope: string; shared: boolean; size: \"sm\" | \"lg\" }) {\n  return (\n    <motion.div\n      className=\"flex w-fit max-w-full origin-top-left flex-col\"\n      layoutId={shared ? `${scope}-heading-${item.id}` : undefined}\n      transition={frameSpring}\n    >\n      <span className={cn(\"truncate font-medium\", size === \"lg\" ? \"text-base\" : \"text-sm\")}>{item.title}</span>\n      {item.meta && <span className=\"truncate text-muted-foreground text-xs\">{item.meta}</span>}\n    </motion.div>\n  );\n}\n\nexport function LiftToOpen({ items, className, label = \"Gallery\" }: LiftToOpenProps) {\n  const scope = useId();\n  const reduce = useCalmMotion();\n  const [radius, radiusProbe] = useThemeRadius(1);\n  const shared = !reduce;\n  const [openId, setOpenId] = useState<string | null>(null);\n  const [closing, setClosing] = useState(false);\n  const [lifted, setLifted] = useState<string | null>(null);\n  const thumbs = useRef(new Map<string, HTMLButtonElement>());\n  const dialog = useRef<HTMLDivElement>(null);\n  const closeButton = useRef<HTMLButtonElement>(null);\n  const returnTo = useRef<string | null>(null);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const open = items.find((item) => item.id === openId) ?? null;\n\n  useEffect(() => () => {\n    if (timer.current) clearTimeout(timer.current);\n  }, []);\n\n  useEffect(() => {\n    if (open) closeButton.current?.focus({ preventScroll: true });\n    else if (returnTo.current) {\n      thumbs.current.get(returnTo.current)?.focus({ preventScroll: true });\n      returnTo.current = null;\n    }\n  }, [open]);\n\n  const show = (id: string) => {\n    if (timer.current) clearTimeout(timer.current);\n    setClosing(false);\n    setLifted(id);\n    setOpenId(id);\n  };\n\n  const close = () => {\n    if (!openId || closing) return;\n    returnTo.current = openId;\n    setClosing(true);\n    timer.current = setTimeout(() => {\n      setOpenId(null);\n      setClosing(false);\n    }, 90);\n  };\n  const closeRef = useRef(close);\n  useEffect(() => {\n    closeRef.current = close;\n  });\n\n  useEffect(() => {\n    if (!openId) return;\n    const outside = (event: PointerEvent) => {\n      if (dialog.current && !dialog.current.contains(event.target as Node)) closeRef.current();\n    };\n    document.addEventListener(\"pointerdown\", outside);\n    return () => document.removeEventListener(\"pointerdown\", outside);\n  }, [openId]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.stopPropagation();\n      close();\n      return;\n    }\n    if (event.key !== \"Tab\" || !dialog.current) return;\n    const focusable = [...dialog.current.querySelectorAll<HTMLElement>(\"button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])\")];\n    if (!focusable.length) return;\n    const first = focusable[0];\n    const last = focusable[focusable.length - 1];\n    if (event.shiftKey && document.activeElement === first) {\n      event.preventDefault();\n      last.focus();\n    } else if (!event.shiftKey && document.activeElement === last) {\n      event.preventDefault();\n      first.focus();\n    }\n  };\n\n  return (\n    <LayoutGroup id={scope}>\n      <div className={cn(\"relative\", className)}>\n        {radiusProbe}\n        <ul aria-label={label} className=\"grid grid-cols-2 gap-3\">\n          {items.map((item) => (\n            <li className=\"relative\" key={item.id} style={{ zIndex: lifted === item.id ? 30 : undefined }}>\n              {openId === item.id ? (\n                <div aria-hidden className=\"invisible flex flex-col gap-2.5 rounded-2xl border p-1.5\">\n                  <div className=\"aspect-[4/3]\" />\n                  <div className=\"px-1.5 pb-1.5 text-sm\">\n                    <div>{item.title}</div>\n                    <div className=\"text-xs\">{item.meta}</div>\n                  </div>\n                </div>\n              ) : (\n                <motion.button\n                  aria-haspopup=\"dialog\"\n                  className=\"flex w-full cursor-pointer flex-col gap-2.5 border bg-card p-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n                  animate={{ opacity: 1, boxShadow: resting }}\n                  initial={reduce ? { opacity: 0 } : lifted === item.id ? { boxShadow: raised } : false}\n                  layoutId={shared ? `${scope}-frame-${item.id}` : undefined}\n                  onClick={() => show(item.id)}\n                  onLayoutAnimationComplete={() => setLifted((id) => (id === item.id ? null : id))}\n                  ref={(el) => {\n                    if (el) thumbs.current.set(item.id, el);\n                    else thumbs.current.delete(item.id);\n                  }}\n                  style={{ borderRadius: radius * 1.6 }}\n                  transition={{ ...frameSpring, boxShadow: { duration: 0.3, ease: \"easeOut\" } }}\n                  type=\"button\"\n                  whileHover={reduce ? undefined : { y: -2, boxShadow: hover }}\n                  whileTap={reduce ? undefined : { scale: 0.97 }}\n                >\n                  <Cover className=\"aspect-[4/3] w-full\" item={item} radius={radius} scope={scope} shared={shared} />\n                  <div className=\"px-1.5 pb-1.5\">\n                    <Heading item={item} scope={scope} shared={shared} size=\"sm\" />\n                  </div>\n                </motion.button>\n              )}\n            </li>\n          ))}\n        </ul>\n        <AnimatePresence>\n          {open && (\n            <motion.div\n              animate={{ opacity: closing ? 0 : 1 }}\n              aria-hidden\n              className=\"absolute -inset-2 z-20 rounded-3xl bg-background/70\"\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0 }}\n              key=\"backdrop\"\n              transition={{ duration: 0.2, ease: \"easeOut\" }}\n            />\n          )}\n        </AnimatePresence>\n        {open && (\n          <motion.div\n            animate={{ opacity: 1, boxShadow: raised }}\n            aria-labelledby={`${scope}-title`}\n            aria-modal=\"true\"\n            className=\"absolute inset-0 z-40 flex flex-col overflow-hidden border bg-card p-1.5\"\n            initial={reduce ? { opacity: 0, boxShadow: raised } : { opacity: 1, boxShadow: resting }}\n            key={open.id}\n            layoutId={shared ? `${scope}-frame-${open.id}` : undefined}\n            onKeyDown={onKeyDown}\n            ref={dialog}\n            role=\"dialog\"\n            style={{ borderRadius: radius * 1.6 }}\n            transition={{ ...frameSpring, opacity: { duration: 0.15 }, boxShadow: { duration: 0.3, ease: \"easeOut\" } }}\n          >\n            <Cover className=\"h-20 w-full shrink-0 sm:h-36\" item={open} radius={radius} scope={scope} shared={shared} />\n            <motion.button\n              animate={{ opacity: closing ? 0 : 1, scale: closing ? 0.9 : 1 }}\n              aria-label=\"Close\"\n              className=\"absolute top-3.5 right-3.5 grid size-8 cursor-pointer place-items-center rounded-full bg-black/25 text-white outline-none backdrop-blur-sm transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-white [@media(hover:hover)]:hover:bg-black/40\"\n              initial={{ opacity: 0, scale: 0.9 }}\n              onClick={close}\n              ref={closeButton}\n              transition={{ duration: closing ? 0.09 : 0.2, delay: closing ? 0 : 0.2, ease: \"easeOut\" }}\n              type=\"button\"\n            >\n              <XIcon className=\"size-4\" />\n            </motion.button>\n            <div className=\"flex min-h-0 flex-1 flex-col gap-3 px-2.5 pt-3 pb-2.5\">\n              <div id={`${scope}-title`}>\n                <Heading item={open} scope={scope} shared={shared} size=\"lg\" />\n              </div>\n              <motion.div\n                animate={closing ? { opacity: 0, y: reduce ? 0 : 4 } : { opacity: 1, y: 0 }}\n                className=\"flex min-h-0 flex-1 flex-col overflow-y-auto\"\n                initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                transition={closing ? { duration: 0.09, ease: \"easeOut\" } : { duration: 0.24, delay: reduce ? 0.05 : 0.22, ease: [0.23, 1, 0.32, 1] }}\n              >\n                {open.details}\n              </motion.div>\n            </div>\n          </motion.div>\n        )}\n      </div>\n    </LayoutGroup>\n  );\n}\n"
    }
  ]
}