{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/card/card.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./card.module.css\";\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\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: .3 };\nconst settle: Transition = { ...spring.smooth, visualDuration: .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: { opacity: 0, y: \"-.3em\", filter: `blur(${blur.subtle}px)`, transition: { duration: duration.fast, ease: [...ease.standard] } },\n};\nconst wordMotion: Variants = {\n  enter: { opacity: 0, y: \".3em\", filter: `blur(${blur.soft}px)` },\n  center: (order: number) => ({ opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: duration.standard, ease: [...ease.enter], delay: order * stagger.word } }),\n};\n\nfunction Status({ text, reduced }: { text: string; reduced: boolean }) {\n  return <span className={styles.status} role=\"status\">\n    <span className={styles.srOnly}>{text}</span>\n    <span className={styles.roll} aria-hidden=\"true\"><AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.span key={text} className={styles.line} variants={lineMotion} initial={reduced ? false : \"enter\"} animate=\"center\" exit={reduced ? undefined : \"exit\"}>\n        {text.split(/(\\s+)/).map((part, index) => <motion.span key={index} className={styles.word} custom={index / 2} variants={wordMotion}>{part}</motion.span>)}\n      </motion.span>\n    </AnimatePresence></span>\n  </span>;\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\") ? amount * parseFloat(getComputedStyle(node.ownerDocument.documentElement).fontSize) : amount;\n}\n\nexport function Card({ title, description, media, action, avatar, meta, status, details, open: openProp, defaultOpen = false, onOpenChange, children, className, style, ...props }: 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((next: boolean) => { if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }, [openProp, onOpenChange]);\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, 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), surface = px(node, computed.getPropertyValue(\"--radius-surface\")), measure = text?.offsetWidth || undefined;\n      setGeometry(current => current.card !== undefined && current.measure === measure ? current : { card: current.card ?? radius, panel: current.panel ?? surface, measure });\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 => morph ? { layoutId: id, layout, layoutCrossfade: false, layoutDependency: side === \"card\" ? open : returning ? \"return\" : \"panel\", transition: { layout: side === \"card\" ? settle : grow } } : {};\n\n  const footer = (side: Side) => avatar || meta || status || action ? <div className={styles.footer}>\n    {avatar || meta || status ? <motion.div className={styles.byline} {...shared(\"byline\", side, \"position\")}>\n      {avatar ? <span className={styles.avatar}>{avatar}</span> : null}\n      <span className={styles.bylineText}>{meta ? <span className={styles.meta}>{meta}</span> : null}{status ? <Status text={status} reduced={reduced} /> : null}</span>\n    </motion.div> : null}\n    {action ? <motion.div className={styles.action} {...shared(\"action\", side, \"position\")}>{action}</motion.div> : null}\n  </div> : null;\n\n  const card = <motion.article\n    {...(props as HTMLMotionProps<\"article\">)}\n    ref={cardRef}\n    className={[styles.card, className].filter(Boolean).join(\" \")}\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 ? <motion.div className={styles.media} {...shared(\"media\", \"card\")}><motion.div className={styles.zoom} initial={false} animate={{ scale: hovered && !reduced ? ZOOM : 1 }} transition={hovered ? zoomIn : zoomOut}>{media}</motion.div></motion.div> : null}\n    <div className={styles.content}>\n      <motion.h3 className={styles.title} {...shared(\"title\", \"card\")}>{details ? <Dialog.Trigger asChild><button type=\"button\" className={styles.trigger}>{title}</button></Dialog.Trigger> : title}</motion.h3>\n      {description ? <motion.p ref={descriptionRef} className={styles.description} {...shared(\"description\", \"card\", \"position\")}>{description}</motion.p> : null}\n      {children}\n      {footer(\"card\")}\n    </div>\n  </motion.article>;\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 <Dialog.Root open={open} onOpenChange={setOpen}>\n    <LayoutGroup id={group}>\n      {card}\n      <AnimatePresence>\n        {(open || returning) && <Dialog.Portal key=\"quick-look\" forceMount>\n          <AnimatePresence>{open && <Dialog.Overlay key=\"overlay\" asChild forceMount><motion.div className={styles.overlay} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: reduced ? fade : { duration: duration.exit, ease: [...ease.standard] } }} transition={reduced ? fade : { duration: duration.standard, ease: [...ease.enter] }} /></Dialog.Overlay>}</AnimatePresence>\n          <Dialog.Content asChild forceMount {...(description ? {} : { \"aria-describedby\": undefined })}>\n            <motion.div\n              className={styles.panel}\n              data-framer-portal-id={group}\n              data-returning={returning || undefined}\n              layoutScroll\n              style={{ borderRadius: geometry.panel, \"--card-measure\": geometry.measure ? `${geometry.measure}px` : undefined, ...(landing && returning ? { \"--landing-top\": `${landing.top}px`, \"--landing-left\": `${landing.left}px`, \"--landing-width\": `${landing.width}px`, \"--landing-height\": `${landing.height}px` } : {}) } as CSSProperties}\n              {...(morph ? {\n                ...shared(\"card\", \"panel\"),\n                initial: false,\n                animate: geometry.card !== undefined && geometry.panel !== undefined ? { borderRadius: returning ? geometry.card : geometry.panel } : undefined,\n                transition: { layout: returning ? settle : grow, borderRadius: returning ? settle : grow },\n                exit: { opacity: 0, transition: { duration: 0 } },\n              } : { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: fade }, transition: fade })}\n            >\n              {media ? <motion.div className={styles.media} {...shared(\"media\", \"panel\")}><motion.div className={styles.zoom} initial={{ scale: hovered && !reduced ? ZOOM : 1 }} animate={{ scale: 1 }} transition={grow}>{media}</motion.div></motion.div> : null}\n              <motion.span className={styles.closeSlot} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .9 }} animate={returning ? { opacity: 0, scale: .9 } : { opacity: 1, scale: 1 }} transition={returning ? fade : reduced ? fade : closeIn}><Dialog.Close asChild><motion.button type=\"button\" className={styles.close} aria-label=\"Close quick look\" whileTap={reduced ? undefined : { scale: .94 }} transition={spring.snappy}><X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\" /></motion.button></Dialog.Close></motion.span>\n              <div className={styles.content}>\n                <Dialog.Title asChild><motion.h2 className={styles.title} {...shared(\"title\", \"panel\")}>{title}</motion.h2></Dialog.Title>\n                {description ? <Dialog.Description asChild><motion.p className={styles.description} {...shared(\"description\", \"panel\", \"position\")}>{description}</motion.p></Dialog.Description> : null}\n                {children}\n                {footer(\"panel\")}\n                <motion.div className={styles.details} initial={reduced ? false : { opacity: 0, y: 6 }} animate={returning ? { opacity: 0, y: 0 } : { opacity: 1, y: 0 }} exit={{ opacity: 0, transition: fade }} transition={returning || reduced ? fade : reveal}>{details}</motion.div>\n              </div>\n            </motion.div>\n          </Dialog.Content>\n        </Dialog.Portal>}\n      </AnimatePresence>\n    </LayoutGroup>\n  </Dialog.Root>;\n}\n"
    },
    {
      "path": "registry/components/card/card.module.css",
      "type": "registry:component",
      "target": "@components/xzero/card/card.module.css",
      "content": "/* Isolation keeps the rounded clip on the media while the card lifts on its own layer (Safari drops it otherwise).\n   Motion drives the lift and the quick look morph, so transform never gets a CSS transition here. */\n.card { position: relative; min-width: 0; overflow: hidden; isolation: isolate; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }\n/* A pointed-at card floats a little, so the shadow arrives with the lift. Touch and pen never lift. */\n.card[data-hover] { border-color: var(--border-strong); box-shadow: var(--shadow-raised); }\n.card:has(.trigger:focus-visible) { border-color: var(--border-strong); }\n.media { position: relative; overflow: hidden; background: var(--surface-muted); }\n.zoom { display: grid; min-height: 112px; place-items: center; }\n.content { padding: var(--space-5); }\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. */\n.title { width: fit-content; max-width: 100%; margin: 0; font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 1.3; text-wrap: balance; }\n.trigger { padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-align: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n/* The title's hit area stretches over the whole card, so pointing anywhere opens the quick look while the action stays on top. */\n.trigger::after { content: \"\"; position: absolute; inset: 0; }\n.trigger:focus-visible { outline: none; }\n.description { max-width: 34ch; margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }\n.footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-top: var(--space-5); }\n.byline { display: flex; flex: 1 1 auto; align-items: center; gap: var(--space-3); min-width: 0; }\n.avatar { display: grid; flex: none; width: var(--space-8); height: var(--space-8); overflow: hidden; border-radius: var(--radius-pill); background: var(--surface-muted); }\n.avatar > * { width: 100%; height: 100%; object-fit: cover; }\n/* A shrinkable column, so a narrow footer truncates the byline instead of sliding it under the action. */\n.bylineText { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; font-size: var(--text-xs); line-height: var(--leading-body); }\n.meta { overflow: hidden; color: var(--foreground); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.status { position: relative; overflow: clip visible; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }\n/* The line clips sideways only, so a status too long for its column ends in an ellipsis while its words still rise in unclipped. */\n.roll { position: relative; display: block; }\n.line { display: block; overflow: clip visible; text-overflow: ellipsis; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.action { position: relative; z-index: 1; display: flex; flex: none; flex-wrap: wrap; gap: var(--space-3); }\n\n.overlay { position: fixed; inset: 0; z-index: 50; background: oklch(10% 0 0 / .46); -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px); }\n/* Centered with auto margins, so transform stays free for the morph. */\n.panel { position: fixed; inset: 0; z-index: 51; width: min(calc(100vw - var(--space-8)), 30rem); height: fit-content; max-height: calc(100dvh - var(--space-8)); margin: auto; overflow: hidden auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }\n.panel:focus { outline: none; }\n/* Closing: the panel takes the card's box and look, so it lands exactly where the card sits and hands back without a seam. */\n.panel[data-returning] { inset: auto; top: var(--landing-top); left: var(--landing-left); width: var(--landing-width); height: var(--landing-height); max-height: none; margin: 0; overflow: hidden; border-color: var(--border); background: var(--surface); box-shadow: none; pointer-events: none; }\n.panel[data-returning] .content { padding: var(--space-5); }\n.panel[data-returning] .title { font-size: var(--text-lg); }\n.panel .content { padding: var(--space-6); }\n.panel .title { font-size: var(--text-xl); }\n.panel .description { max-width: var(--card-measure, 34ch); }\n/* The slot carries the entrance, so the button's own press answers instantly. */\n.closeSlot { position: absolute; top: var(--space-4); right: var(--space-4); z-index: 2; display: grid; }\n.close { display: grid; width: var(--space-8); height: var(--space-8); place-items: center; padding: 0; border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); border-radius: var(--radius-pill); background: color-mix(in oklab, var(--surface-raised) 74%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .close:hover { background: var(--surface-raised); } }\n.details { margin-top: var(--space-6); padding-top: var(--space-5); border-top: 1px solid var(--border); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n\n@media (prefers-reduced-motion: reduce) {\n  .card, .close { transition-duration: var(--duration-instant); }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/card",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "surface",
      "layout"
    ],
    "docs": "https://ui.xzero.co.uk/components/card",
    "markdown": "https://ui.xzero.co.uk/components/card/markdown"
  }
}