{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "casement",
  "type": "registry:ui",
  "title": "Casement",
  "description": "A list or grid of dark-glass cards that morph into a centered overlay via a genuine shared-element FLIP — media, title, and action travel continuously, radii stay unsheared, the frame overshoots ~1% and settles, and body copy fades up only after it lands. Full dialog semantics with focus trap, Escape, and outside-click; reduced motion swaps instantly.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/cards/casement/casement.tsx",
      "type": "registry:ui",
      "target": "components/crucible/casement.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, type Transition } from \"motion/react\";\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/** A single expandable entry in a {@link Casement}. */\nexport interface CasementItem {\n  /** Stable, unique id — drives the shared-element `layoutId`s and controlled state. */\n  id: string;\n  /** Headline shown collapsed and expanded — travels through the morph. */\n  title: string;\n  /** Secondary line under the title (artist, category, timestamp…). */\n  meta?: string;\n  /** Optional media URL. When omitted, a deterministic dark gradient stands in. */\n  image?: string;\n  /** Alt text for `image`. Decorative media should pass `\"\"`. */\n  imageAlt?: string;\n  /** Accent hex used to tint the placeholder gradient (and the media crossfade). */\n  accent?: string;\n  /** Label for the action control. @default \"View\" */\n  action?: string;\n  /** When set, the expanded action renders as a real link to this href. */\n  actionHref?: string;\n  /** Rich body copy revealed *after* the morph settles — never participates in the FLIP. */\n  content?: React.ReactNode;\n}\n\nexport interface CasementProps {\n  /** The cards to render. Each expands into a centered overlay on click. */\n  items: CasementItem[];\n  /** Collapsed arrangement. @default \"list\" */\n  layout?: \"list\" | \"grid\";\n  /** Controlled active id (`null` = closed). Omit for uncontrolled use. */\n  activeId?: string | null;\n  /** Initial active id in uncontrolled mode. @default null */\n  defaultActiveId?: string | null;\n  /** Fires whenever the active card changes (open with an id, close with `null`). */\n  onActiveChange?: (id: string | null) => void;\n  /** Called when a non-link action button is pressed in the expanded card. */\n  onAction?: (item: CasementItem) => void;\n  /** Backdrop dim opacity, 0–1. @default 0.62 */\n  backdropOpacity?: number;\n  /** Backdrop blur radius in px (the \"breath of blur\"). @default 8 */\n  backdropBlur?: number;\n  /** Expanded card max width in px. @default 460 */\n  expandedMaxWidth?: number;\n  /** Close when the backdrop is clicked. @default true */\n  closeOnOutsideClick?: boolean;\n  /** Close on the Escape key. @default true */\n  closeOnEscape?: boolean;\n  /** Morph the media through the FLIP. When false it crossfades instead. @default true */\n  morphMedia?: boolean;\n  /** Single cool accent hex for focus rings / hover. @default \"#93c5fd\" */\n  accent?: string;\n  /** Extra classes for the collapsed list/grid wrapper. */\n  className?: string;\n}\n\n/** FNV-1a — deterministic so the placeholder gradient is identical across SSR/CSR. */\nfunction hashString(input: string): number {\n  let h = 2166136261;\n  for (let i = 0; i < input.length; i++) {\n    h ^= input.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return h >>> 0;\n}\n\nfunction mediaBackground(item: CasementItem): string {\n  if (item.accent) return `linear-gradient(140deg, ${item.accent} -12%, #0a0c11 118%)`;\n  const h = hashString(item.id) % 360;\n  return `linear-gradient(140deg, hsl(${h} 22% 33%) -12%, hsl(${(h + 32) % 360} 20% 11%) 118%)`;\n}\n\nconst FOCUSABLE =\n  'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Casement — a list or grid of dark-glass cards where clicking one performs a\n * genuine shared-element morph: the media, title, and action travel continuously\n * from their collapsed seats into a centered overlay card. It overshoots ~1% and\n * settles like a sash meeting its frame, corner radii stay continuous through the\n * FLIP (no shearing), the backdrop dims and gains a breath of blur, siblings\n * recede, and body copy fades up only *after* the frame lands — kept crisp. Full\n * dialog semantics: focus moves in and traps, Escape and outside-click close,\n * focus returns to the originating card. Reduced motion swaps instantly with a\n * plain crossfade and no blur animation.\n */\nexport function Casement({\n  items,\n  layout = \"list\",\n  activeId,\n  defaultActiveId = null,\n  onActiveChange,\n  onAction,\n  backdropOpacity = 0.62,\n  backdropBlur = 8,\n  expandedMaxWidth = 460,\n  closeOnOutsideClick = true,\n  closeOnEscape = true,\n  morphMedia = true,\n  accent = \"#93c5fd\",\n  className,\n}: CasementProps) {\n  const reducedMotion = useReducedMotion();\n  const uid = React.useId().replace(/[:]/g, \"\");\n\n  // --- controlled / uncontrolled active state ------------------------------\n  const isControlled = activeId !== undefined;\n  const [uncontrolled, setUncontrolled] = React.useState<string | null>(defaultActiveId);\n  const currentId = isControlled ? activeId! : uncontrolled;\n  const activeItem = React.useMemo(\n    () => items.find((it) => it.id === currentId) ?? null,\n    [items, currentId]\n  );\n\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const dialogRef = React.useRef<HTMLDivElement>(null);\n  const [landed, setLanded] = React.useState(false);\n  const [mounted, setMounted] = React.useState(false);\n\n  React.useEffect(() => setMounted(true), []);\n\n  const setActive = React.useCallback(\n    (id: string | null) => {\n      if (!isControlled) setUncontrolled(id);\n      onActiveChange?.(id);\n    },\n    [isControlled, onActiveChange]\n  );\n\n  const open = React.useCallback(\n    (id: string, node: HTMLButtonElement) => {\n      triggerRef.current = node;\n      setLanded(false);\n      setActive(id);\n    },\n    [setActive]\n  );\n\n  const close = React.useCallback(() => {\n    setActive(null);\n    // Return focus to the card that opened this dialog, once React has committed.\n    const el = triggerRef.current;\n    requestAnimationFrame(() => el?.focus());\n  }, [setActive]);\n\n  // Reset the body-copy reveal whenever the active card changes. If the layout\n  // animation never reports completion (e.g. opened on mount with nothing to\n  // morph from), reveal the body anyway so it can never stay hidden.\n  React.useEffect(() => {\n    if (!activeItem) {\n      setLanded(false);\n      return;\n    }\n    const t = window.setTimeout(() => setLanded(true), 900);\n    return () => window.clearTimeout(t);\n  }, [activeItem]);\n\n  // --- focus trap + Escape --------------------------------------------------\n  React.useEffect(() => {\n    if (!activeItem) return;\n    const dialog = dialogRef.current;\n    const raf = requestAnimationFrame(() => dialog?.focus());\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        if (closeOnEscape) {\n          e.preventDefault();\n          e.stopPropagation();\n          close();\n        }\n        return;\n      }\n      if (e.key !== \"Tab\" || !dialog) return;\n      const focusable = Array.from(\n        dialog.querySelectorAll<HTMLElement>(FOCUSABLE)\n      ).filter((el) => el.offsetParent !== null || el === document.activeElement);\n      if (focusable.length === 0) {\n        e.preventDefault();\n        return;\n      }\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      if (e.shiftKey) {\n        if (active === first || active === dialog) {\n          e.preventDefault();\n          last.focus();\n        }\n      } else if (active === last) {\n        e.preventDefault();\n        first.focus();\n      }\n    };\n\n    document.addEventListener(\"keydown\", onKeyDown, true);\n    return () => {\n      cancelAnimationFrame(raf);\n      document.removeEventListener(\"keydown\", onKeyDown, true);\n    };\n  }, [activeItem, closeOnEscape, close]);\n\n  // --- scroll lock (scrollbar-compensated, no layout jump) ------------------\n  React.useEffect(() => {\n    if (!activeItem) return;\n    const { body, documentElement: html } = document;\n    const gap = window.innerWidth - html.clientWidth;\n    const prevOverflow = body.style.overflow;\n    const prevPad = body.style.paddingRight;\n    body.style.overflow = \"hidden\";\n    if (gap > 0) body.style.paddingRight = `${gap}px`;\n    return () => {\n      body.style.overflow = prevOverflow;\n      body.style.paddingRight = prevPad;\n    };\n  }, [activeItem]);\n\n  // --- transitions ----------------------------------------------------------\n  const morphTransition: Transition = reducedMotion\n    ? { duration: 0 }\n    : { type: \"spring\", stiffness: 330, damping: 30, mass: 0.9 };\n\n  const idFor = (kind: string, itemId: string) => `${uid}-${kind}-${itemId}`;\n\n  const isList = layout === \"list\";\n\n  return (\n    <div\n      data-crucible=\"casement\"\n      className={cn(\"w-full\", className)}\n      style={{ [\"--casement-accent\" as string]: accent }}\n    >\n      {/* Collapsed cells. The whole cell is a real button; the inner media /\n          title / action are decorative shared elements that morph. */}\n      <motion.div\n        animate={{\n          opacity: activeItem ? 0.55 : 1,\n          scale: activeItem && !reducedMotion ? 0.985 : 1,\n        }}\n        transition={reducedMotion ? { duration: 0.15 } : { type: \"spring\", stiffness: 300, damping: 30 }}\n        className={cn(\n          isList\n            ? \"flex flex-col gap-2\"\n            : \"grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3\"\n        )}\n      >\n        {items.map((item) => {\n          const isActive = item.id === currentId;\n          return (\n            <motion.button\n              key={item.id}\n              type=\"button\"\n              layoutId={idFor(\"card\", item.id)}\n              onClick={(e) => open(item.id, e.currentTarget)}\n              aria-haspopup=\"dialog\"\n              aria-expanded={isActive}\n              aria-label={`${item.title}${item.meta ? `, ${item.meta}` : \"\"} — expand`}\n              transition={morphTransition}\n              style={{ borderRadius: 16 }}\n              // Hide the collapsed cell while it is the active (morphed) card so\n              // the grid keeps its slot but nothing double-renders behind the dialog.\n              animate={{ opacity: isActive ? 0 : 1 }}\n              className={cn(\n                \"group relative overflow-hidden border border-white/10 bg-white/[0.035] text-left outline-none\",\n                \"transition-colors hover:border-white/20 hover:bg-white/[0.06]\",\n                \"focus-visible:border-[var(--casement-accent)] focus-visible:ring-1 focus-visible:ring-[var(--casement-accent)]\",\n                isList ? \"flex items-center gap-4 p-2.5\" : \"flex flex-col\"\n              )}\n            >\n              <motion.span\n                layoutId={morphMedia ? idFor(\"media\", item.id) : undefined}\n                aria-hidden\n                transition={morphTransition}\n                style={{\n                  borderRadius: isList ? 12 : 0,\n                  background: mediaBackground(item),\n                  backgroundImage: item.image ? `url(${item.image})` : undefined,\n                  backgroundSize: \"cover\",\n                  backgroundPosition: \"center\",\n                }}\n                className={cn(\n                  \"block shrink-0\",\n                  isList ? \"size-14\" : \"aspect-[16/10] w-full\"\n                )}\n              />\n              <span className={cn(\"min-w-0\", isList ? \"flex-1\" : \"p-3.5\")}>\n                <motion.span\n                  layoutId={idFor(\"title\", item.id)}\n                  transition={morphTransition}\n                  className=\"block truncate text-[15px] font-semibold tracking-tight text-white\"\n                >\n                  {item.title}\n                </motion.span>\n                {item.meta ? (\n                  <motion.span\n                    layoutId={idFor(\"meta\", item.id)}\n                    transition={morphTransition}\n                    className=\"mt-0.5 block truncate text-xs text-neutral-400\"\n                  >\n                    {item.meta}\n                  </motion.span>\n                ) : null}\n              </span>\n              <motion.span\n                layoutId={idFor(\"action\", item.id)}\n                aria-hidden\n                transition={morphTransition}\n                className={cn(\n                  \"shrink-0 rounded-full border border-white/15 bg-white/[0.06] px-3.5 py-1.5 text-xs font-medium text-white/90\",\n                  \"transition-colors group-hover:border-white/30 group-hover:bg-white/10\",\n                  isList ? \"mr-1.5\" : \"mx-3.5 mb-3.5 self-start\"\n                )}\n              >\n                {item.action ?? \"View\"}\n              </motion.span>\n            </motion.button>\n          );\n        })}\n      </motion.div>\n\n      {/* One overlay portal for the expanded card + backdrop. */}\n      {mounted &&\n        createPortal(\n          <AnimatePresence>\n            {activeItem ? (\n              <div\n                key=\"casement-overlay\"\n                style={{ [\"--casement-accent\" as string]: accent }}\n              >\n                {/* Backdrop: dims + a breath of blur. Reduced motion fades the\n                    dim only, with the blur applied statically (never animated). */}\n                <motion.div\n                  aria-hidden\n                  className=\"fixed inset-0 z-[90]\"\n                  style={{ backgroundColor: `rgba(4, 5, 8, ${backdropOpacity})` }}\n                  initial={\n                    reducedMotion\n                      ? { opacity: 0, backdropFilter: `blur(${backdropBlur}px)` }\n                      : { opacity: 0, backdropFilter: \"blur(0px)\" }\n                  }\n                  animate={{ opacity: 1, backdropFilter: `blur(${backdropBlur}px)` }}\n                  exit={{ opacity: 0 }}\n                  transition={\n                    reducedMotion\n                      ? { duration: 0.12 }\n                      : { duration: 0.34, ease: [0.22, 1, 0.36, 1] }\n                  }\n                />\n\n                {/* Centering layer doubles as the outside-click catcher. */}\n                <div\n                  className=\"fixed inset-0 z-[91] flex items-center justify-center p-4\"\n                  onMouseDown={(e) => {\n                    if (e.target === e.currentTarget && closeOnOutsideClick) close();\n                  }}\n                >\n                  <motion.div\n                    ref={dialogRef}\n                    role=\"dialog\"\n                    aria-modal=\"true\"\n                    aria-labelledby={`${uid}-dialog-title`}\n                    tabIndex={-1}\n                    layoutId={idFor(\"card\", activeItem.id)}\n                    transition={morphTransition}\n                    onLayoutAnimationComplete={() => setLanded(true)}\n                    style={{\n                      borderRadius: 16,\n                      width: \"100%\",\n                      maxWidth: expandedMaxWidth,\n                    }}\n                    className=\"relative flex max-h-[86vh] flex-col overflow-hidden border border-white/10 bg-neutral-900/85 shadow-2xl shadow-black/60 outline-none ring-1 ring-white/[0.06] backdrop-blur-xl\"\n                  >\n                    {/* Header media — the same shared element, now full-bleed. */}\n                    <div className=\"relative\">\n                      {morphMedia ? (\n                        <motion.div\n                          layoutId={idFor(\"media\", activeItem.id)}\n                          aria-hidden\n                          transition={morphTransition}\n                          style={{\n                            borderRadius: 0,\n                            background: mediaBackground(activeItem),\n                            backgroundImage: activeItem.image\n                              ? `url(${activeItem.image})`\n                              : undefined,\n                            backgroundSize: \"cover\",\n                            backgroundPosition: \"center\",\n                          }}\n                          className=\"h-44 w-full\"\n                        />\n                      ) : (\n                        <motion.div\n                          aria-hidden\n                          initial={{ opacity: 0 }}\n                          animate={{ opacity: 1 }}\n                          transition={{ duration: 0.3 }}\n                          style={{\n                            background: mediaBackground(activeItem),\n                            backgroundImage: activeItem.image\n                              ? `url(${activeItem.image})`\n                              : undefined,\n                            backgroundSize: \"cover\",\n                            backgroundPosition: \"center\",\n                          }}\n                          className=\"h-44 w-full\"\n                        />\n                      )}\n\n                      <button\n                        type=\"button\"\n                        onClick={close}\n                        aria-label=\"Close\"\n                        className=\"absolute right-3 top-3 grid size-8 place-items-center rounded-full border border-white/10 bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--casement-accent)]\"\n                      >\n                        <svg\n                          width=\"14\"\n                          height=\"14\"\n                          viewBox=\"0 0 14 14\"\n                          fill=\"none\"\n                          aria-hidden\n                        >\n                          <path\n                            d=\"M1 1l12 12M13 1L1 13\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"1.6\"\n                            strokeLinecap=\"round\"\n                          />\n                        </svg>\n                      </button>\n                    </div>\n\n                    {/* Title + meta + action travel; body copy does not. */}\n                    <div className=\"flex items-start gap-3 p-5\">\n                      <div className=\"min-w-0 flex-1\">\n                        <motion.h3\n                          layoutId={idFor(\"title\", activeItem.id)}\n                          id={`${uid}-dialog-title`}\n                          transition={morphTransition}\n                          className=\"text-lg font-semibold tracking-tight text-white\"\n                        >\n                          {activeItem.title}\n                        </motion.h3>\n                        {activeItem.meta ? (\n                          <motion.p\n                            layoutId={idFor(\"meta\", activeItem.id)}\n                            transition={morphTransition}\n                            className=\"mt-1 text-sm text-neutral-400\"\n                          >\n                            {activeItem.meta}\n                          </motion.p>\n                        ) : null}\n                      </div>\n\n                      {activeItem.actionHref ? (\n                        <motion.a\n                          layoutId={idFor(\"action\", activeItem.id)}\n                          href={activeItem.actionHref}\n                          transition={morphTransition}\n                          className=\"shrink-0 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--casement-accent)]\"\n                        >\n                          {activeItem.action ?? \"View\"}\n                        </motion.a>\n                      ) : (\n                        <motion.button\n                          layoutId={idFor(\"action\", activeItem.id)}\n                          type=\"button\"\n                          onClick={() => onAction?.(activeItem)}\n                          transition={morphTransition}\n                          className=\"shrink-0 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--casement-accent)]\"\n                        >\n                          {activeItem.action ?? \"View\"}\n                        </motion.button>\n                      )}\n                    </div>\n\n                    {activeItem.content ? (\n                      <motion.div\n                        initial={reducedMotion ? false : { opacity: 0, y: 10 }}\n                        animate={\n                          landed || reducedMotion\n                            ? { opacity: 1, y: 0 }\n                            : { opacity: 0, y: 10 }\n                        }\n                        transition={{\n                          delay: reducedMotion ? 0 : 0.06,\n                          duration: 0.3,\n                          ease: [0.22, 1, 0.36, 1],\n                        }}\n                        className=\"min-h-0 flex-1 overflow-y-auto px-5 pb-6 text-sm leading-relaxed text-neutral-300 [mask-image:linear-gradient(to_bottom,#000_calc(100%-2rem),transparent)]\"\n                      >\n                        {activeItem.content}\n                      </motion.div>\n                    ) : null}\n                  </motion.div>\n                </div>\n              </div>\n            ) : null}\n          </AnimatePresence>,\n          document.body\n        )}\n    </div>\n  );\n}\n"
    }
  ]
}