{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card",
  "type": "registry:ui",
  "title": "Card",
  "description": "A contained group of related content and actions.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@radix-ui/react-dialog",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/card/card.tsx",
      "content": "\"use client\";\n\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, HTMLAttributes, ReactNode } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport type { HTMLMotionProps, MotionProps, Transition, Variants } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface CardProps extends HTMLAttributes<HTMLElement> {\n  title: string;\n  description?: string;\n  media?: ReactNode;\n  action?: ReactNode;\n  /** A small leading visual for the footer, such as the owner's avatar. */\n  avatar?: ReactNode;\n  /** Who the card belongs to, such as the owner's name. */\n  meta?: ReactNode;\n  /** A short status under the meta, such as \"Updated 2 hours ago\". Changed words rise in and are announced politely. */\n  status?: string;\n  /** Content for a quick look. When set, the whole card opens and grows into a larger view; Escape or the close control morphs it back. */\n  details?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\n/** Fit-content width keeps the title's box the shape of its text, so it scales evenly when it grows into the quick look. */\nconst titleClass = \"m-0 w-fit max-w-full font-medium tracking-body text-balance\";\nconst descriptionClass = \"mt-2 text-sm leading-body text-pretty text-ink-2\";\nconst mediaClass = \"relative overflow-hidden bg-hover\";\nconst zoomClass = \"grid min-h-[112px] place-items-center\";\n\ntype Side = \"card\" | \"panel\";\ntype Geometry = { card?: number; panel?: number; measure?: number };\ntype Landing = { top: number; left: number; width: number; height: number };\n\nconst { blur, duration, ease, spring, stagger } = motionTokens;\n/** One critically damped spring carries the surface both ways, so it never overshoots and can reverse mid-flight. Closing is a touch quicker. */\nconst grow: Transition = { ...spring.smooth, visualDuration: 0.3 };\nconst settle: Transition = { ...spring.smooth, visualDuration: 0.26 };\nconst RETURN_MS = 300;\nconst fade: Transition = { duration: duration.instant };\n/** The photo drifts in slowly while the card is pointed at, and eases back a little faster. */\nconst ZOOM = 1.04;\nconst zoomIn: Transition = { duration: duration.considered * 2, ease: [...ease.standard] };\nconst zoomOut: Transition = { duration: duration.considered, ease: [...ease.standard] };\n/** Quick look extras arrive once the surface has mostly grown, so they never ride the stretch. */\nconst reveal: Transition = { delay: duration.instant, duration: duration.standard, ease: [...ease.enter] };\nconst closeIn: Transition = { delay: duration.instant, duration: duration.fast, ease: [...ease.enter] };\n\n/** A new status replaces the whole line: the old one lifts away quickly while the new words rise in one after another. */\nconst lineMotion: Variants = {\n  enter: {},\n  center: {},\n  exit: {\n    opacity: 0,\n    y: \"-.3em\",\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: duration.fast, ease: [...ease.standard] },\n  },\n};\nconst wordMotion: Variants = {\n  enter: { opacity: 0, y: \".3em\", filter: `blur(${blur.soft}px)` },\n  center: (order: number) => ({\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: duration.standard, ease: [...ease.enter], delay: order * stagger.word },\n  }),\n};\n\nfunction Status({ text, reduced }: { text: string; reduced: boolean }) {\n  return (\n    <span\n      className=\"relative overflow-x-clip overflow-y-visible whitespace-nowrap text-ink-3 tabular-nums\"\n      role=\"status\"\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span className=\"relative block\" aria-hidden=\"true\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={text}\n            className=\"block overflow-x-clip overflow-y-visible text-ellipsis\"\n            variants={lineMotion}\n            initial={reduced ? false : \"enter\"}\n            animate=\"center\"\n            exit={reduced ? undefined : \"exit\"}\n          >\n            {text.split(/(\\s+)/).map((part, index) => (\n              <motion.span key={index} className=\"inline-block whitespace-pre\" custom={index / 2} variants={wordMotion}>\n                {part}\n              </motion.span>\n            ))}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n\n/** Motion only scale-corrects pixel radii, so token radii are read back in pixels for the morph. */\nfunction px(node: Element, value: string) {\n  const amount = parseFloat(value);\n  if (!Number.isFinite(amount)) return undefined;\n  return value.trim().endsWith(\"rem\")\n    ? amount * parseFloat(getComputedStyle(node.ownerDocument.documentElement).fontSize)\n    : amount;\n}\n\nexport function Card({\n  title,\n  description,\n  media,\n  action,\n  avatar,\n  meta,\n  status,\n  details,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  children,\n  className,\n  style,\n  ...props\n}: CardProps) {\n  const reduced = useReducedMotion() ?? false;\n  const group = useId();\n  const cardRef = useRef<HTMLElement>(null);\n  const descriptionRef = useRef<HTMLParagraphElement>(null);\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const [hovered, setHovered] = useState(false);\n  const [geometry, setGeometry] = useState<Geometry>({});\n  /** Where the quick look lands when it closes: the card's box on screen. The panel travels there above the page, then hands back to the card. */\n  const [landing, setLanding] = useState<Landing | null>(null);\n  const open = details ? (openProp ?? uncontrolled) : false;\n  const morph = Boolean(details) && !reduced;\n  const returning = !open && landing !== null;\n  const hasDescription = Boolean(description);\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setUncontrolled(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  // The quick look keeps the card's line length, so the description never rewraps while it travels.\n  useEffect(() => {\n    const node = cardRef.current,\n      text = descriptionRef.current;\n    if (!morph || !node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const computed = getComputedStyle(node);\n      const radius = px(node, computed.borderTopLeftRadius),\n        surface = px(node, computed.getPropertyValue(\"--radius-surface\")),\n        measure = text?.offsetWidth || undefined;\n      setGeometry((current) =>\n        current.card !== undefined && current.measure === measure\n          ? current\n          : { card: current.card ?? radius, panel: current.panel ?? surface, measure },\n      );\n    });\n    observer.observe(node);\n    if (text) observer.observe(text);\n    return () => observer.disconnect();\n  }, [morph, hasDescription]);\n\n  // Closing keeps the quick look mounted and sends it back to the card's box, so the surface never drops behind the page or a clipped preview.\n  const wasOpen = useRef(open);\n  useLayoutEffect(() => {\n    const closed = wasOpen.current && !open;\n    wasOpen.current = open;\n    const node = cardRef.current;\n    if (open) setLanding(null); // eslint-disable-line react-hooks/set-state-in-effect -- reopening mid-return cancels the landing\n    else if (closed && morph && node) {\n      const box = node.getBoundingClientRect();\n      setLanding({ top: box.top, left: box.left, width: box.width, height: box.height });\n    }\n  }, [open, morph]);\n  // Once the surface has visually landed, the card takes over. Any last sub-pixel of travel carries on in the card itself, so the handoff has no seam.\n  useEffect(() => {\n    if (!returning) return;\n    const timer = window.setTimeout(() => setLanding(null), RETURN_MS);\n    return () => window.clearTimeout(timer);\n  }, [returning]);\n\n  /** The same pieces live in the card and in the quick look; a shared id lets each one travel between them.\n      Crossfade is off: the arriving piece takes over at full opacity and the other hides, so one solid surface moves instead of two translucent copies. */\n  const shared = (id: string, side: Side, layout: true | \"position\" = true): MotionProps =>\n    morph\n      ? {\n          layoutId: id,\n          layout,\n          layoutCrossfade: false,\n          layoutDependency: side === \"card\" ? open : returning ? \"return\" : \"panel\",\n          transition: { layout: side === \"card\" ? settle : grow },\n        }\n      : {};\n\n  const footer = (side: Side) =>\n    avatar || meta || status || action ? (\n      <div className=\"mt-5 flex items-center justify-between gap-3\">\n        {avatar || meta || status ? (\n          <motion.div className=\"flex min-w-0 flex-auto items-center gap-3\" {...shared(\"byline\", side, \"position\")}>\n            {avatar ? (\n              <span className=\"grid size-8 flex-none overflow-hidden rounded-full bg-hover [&>*]:size-full [&>*]:object-cover\">\n                {avatar}\n              </span>\n            ) : null}\n            <span className=\"grid min-w-0 grid-cols-[minmax(0,1fr)] text-xs leading-body\">\n              {meta ? <span className=\"truncate font-medium text-ink\">{meta}</span> : null}\n              {status ? <Status text={status} reduced={reduced} /> : null}\n            </span>\n          </motion.div>\n        ) : null}\n        {action ? (\n          <motion.div className=\"relative z-1 flex flex-none flex-wrap gap-3\" {...shared(\"action\", side, \"position\")}>\n            {action}\n          </motion.div>\n        ) : null}\n      </div>\n    ) : null;\n\n  const card = (\n    <motion.article\n      {...(props as HTMLMotionProps<\"article\">)}\n      ref={cardRef}\n      className={cn(\n        \"relative isolate min-w-0 overflow-hidden rounded-panel border border-line bg-surface text-ink [transition:border-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-standard)_var(--ease-standard)] has-[h3>button:focus-visible]:border-line-strong data-hover:border-line-strong data-hover:shadow-raised motion-reduce:duration-instant\",\n        className,\n      )}\n      style={{ ...style, borderRadius: geometry.card ?? style?.borderRadius }}\n      data-hover={hovered || undefined}\n      whileHover={reduced ? undefined : { y: -2 }}\n      onHoverStart={() => setHovered(true)}\n      onHoverEnd={() => setHovered(false)}\n      {...shared(\"card\", \"card\")}\n      transition={{ default: spring.snappy, layout: settle }}\n    >\n      {media ? (\n        <motion.div className={mediaClass} {...shared(\"media\", \"card\")}>\n          <motion.div\n            className={zoomClass}\n            initial={false}\n            animate={{ scale: hovered && !reduced ? ZOOM : 1 }}\n            transition={hovered ? zoomIn : zoomOut}\n          >\n            {media}\n          </motion.div>\n        </motion.div>\n      ) : null}\n      <div className=\"p-5\">\n        <motion.h3 className={cn(titleClass, \"text-lg/[1.3]\")} {...shared(\"title\", \"card\")}>\n          {details ? (\n            <Dialog.Trigger asChild>\n              <button\n                type=\"button\"\n                className=\"cursor-pointer border-0 bg-transparent p-0 [text-align:inherit] [-webkit-tap-highlight-color:transparent] after:absolute after:inset-0 after:content-['']\"\n              >\n                {title}\n              </button>\n            </Dialog.Trigger>\n          ) : (\n            title\n          )}\n        </motion.h3>\n        {description ? (\n          <motion.p\n            ref={descriptionRef}\n            className={cn(descriptionClass, \"max-w-[34ch]\")}\n            {...shared(\"description\", \"card\", \"position\")}\n          >\n            {description}\n          </motion.p>\n        ) : null}\n        {children}\n        {footer(\"card\")}\n      </div>\n    </motion.article>\n  );\n\n  if (!details) return card;\n\n  // The quick look grows out of the card: the surface, photo, and copy travel on one spring while the details settle in beneath them.\n  return (\n    <Dialog.Root open={open} onOpenChange={setOpen}>\n      <LayoutGroup id={group}>\n        {card}\n        <AnimatePresence>\n          {(open || returning) && (\n            <Dialog.Portal key=\"quick-look\" forceMount>\n              <AnimatePresence>\n                {open && (\n                  <Dialog.Overlay key=\"overlay\" asChild forceMount>\n                    <motion.div\n                      className=\"fixed inset-0 z-50 bg-[oklch(10%_0_0/.46)] backdrop-blur-[7px]\"\n                      initial={{ opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      exit={{\n                        opacity: 0,\n                        transition: reduced ? fade : { duration: duration.exit, ease: [...ease.standard] },\n                      }}\n                      transition={reduced ? fade : { duration: duration.standard, ease: [...ease.enter] }}\n                    />\n                  </Dialog.Overlay>\n                )}\n              </AnimatePresence>\n              <Dialog.Content asChild forceMount {...(description ? {} : { \"aria-describedby\": undefined })}>\n                <motion.div\n                  className=\"fixed inset-0 z-51 m-auto h-fit max-h-[calc(100dvh_-_var(--space-8))] w-[min(calc(100vw_-_var(--space-8)),30rem)] overflow-x-hidden overflow-y-auto overscroll-contain rounded-surface border border-line bg-surface text-ink shadow-floating transition-[background-color,box-shadow] duration-standard ease-standard focus:outline-none data-returning:pointer-events-none data-returning:inset-auto data-returning:top-(--landing-top) data-returning:left-(--landing-left) data-returning:m-0 data-returning:h-(--landing-height) data-returning:max-h-none data-returning:w-(--landing-width) data-returning:overflow-hidden data-returning:border-line data-returning:bg-surface data-returning:shadow-none\"\n                  data-framer-portal-id={group}\n                  data-returning={returning || undefined}\n                  layoutScroll\n                  style={\n                    {\n                      borderRadius: geometry.panel,\n                      \"--card-measure\": geometry.measure ? `${geometry.measure}px` : undefined,\n                      ...(landing && returning\n                        ? {\n                            \"--landing-top\": `${landing.top}px`,\n                            \"--landing-left\": `${landing.left}px`,\n                            \"--landing-width\": `${landing.width}px`,\n                            \"--landing-height\": `${landing.height}px`,\n                          }\n                        : {}),\n                    } as CSSProperties\n                  }\n                  {...(morph\n                    ? {\n                        ...shared(\"card\", \"panel\"),\n                        initial: false,\n                        animate:\n                          geometry.card !== undefined && geometry.panel !== undefined\n                            ? { borderRadius: returning ? geometry.card : geometry.panel }\n                            : undefined,\n                        transition: { layout: returning ? settle : grow, borderRadius: returning ? settle : grow },\n                        exit: { opacity: 0, transition: { duration: 0 } },\n                      }\n                    : {\n                        initial: { opacity: 0 },\n                        animate: { opacity: 1 },\n                        exit: { opacity: 0, transition: fade },\n                        transition: fade,\n                      })}\n                >\n                  {media ? (\n                    <motion.div className={mediaClass} {...shared(\"media\", \"panel\")}>\n                      <motion.div\n                        className={zoomClass}\n                        initial={{ scale: hovered && !reduced ? ZOOM : 1 }}\n                        animate={{ scale: 1 }}\n                        transition={grow}\n                      >\n                        {media}\n                      </motion.div>\n                    </motion.div>\n                  ) : null}\n                  <motion.span\n                    className=\"absolute top-4 right-4 z-2 grid\"\n                    initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n                    animate={returning ? { opacity: 0, scale: 0.9 } : { opacity: 1, scale: 1 }}\n                    transition={returning ? fade : reduced ? fade : closeIn}\n                  >\n                    <Dialog.Close asChild>\n                      <motion.button\n                        type=\"button\"\n                        className=\"grid size-8 cursor-pointer place-items-center rounded-button border border-[color-mix(in_oklab,var(--line)_70%,transparent)] bg-[color-mix(in_oklab,var(--surface)_74%,transparent)] p-0 text-ink backdrop-blur-[12px] transition-[background-color] duration-fast ease-standard [-webkit-tap-highlight-color:transparent] hover:bg-surface motion-reduce:duration-instant\"\n                        aria-label=\"Close quick look\"\n                        whileTap={reduced ? undefined : { scale: 0.94 }}\n                        transition={spring.snappy}\n                      >\n                        <X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                      </motion.button>\n                    </Dialog.Close>\n                  </motion.span>\n                  <div className={returning ? \"p-5\" : \"p-6\"}>\n                    <Dialog.Title asChild>\n                      <motion.h2\n                        className={cn(titleClass, returning ? \"text-lg/[1.3]\" : \"text-xl/[1.3]\")}\n                        {...shared(\"title\", \"panel\")}\n                      >\n                        {title}\n                      </motion.h2>\n                    </Dialog.Title>\n                    {description ? (\n                      <Dialog.Description asChild>\n                        <motion.p\n                          className={cn(descriptionClass, \"max-w-(--card-measure,34ch)\")}\n                          {...shared(\"description\", \"panel\", \"position\")}\n                        >\n                          {description}\n                        </motion.p>\n                      </Dialog.Description>\n                    ) : null}\n                    {children}\n                    {footer(\"panel\")}\n                    <motion.div\n                      className=\"mt-6 border-t border-line pt-5 text-sm leading-body text-ink-2\"\n                      initial={reduced ? false : { opacity: 0, y: 6 }}\n                      animate={returning ? { opacity: 0, y: 0 } : { opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, transition: fade }}\n                      transition={returning || reduced ? fade : reveal}\n                    >\n                      {details}\n                    </motion.div>\n                  </div>\n                </motion.div>\n              </Dialog.Content>\n            </Dialog.Portal>\n          )}\n        </AnimatePresence>\n      </LayoutGroup>\n    </Dialog.Root>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/card/card.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/card",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "Card",
    "tags": [
      "surface",
      "layout"
    ],
    "docs": "https://irisui.dev/components/card"
  }
}
