{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "card", "title": "Card", "description": "shadcn's compositional card (Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter) with the Zeron Design layer: transparent substrate, weight-animated title, media/logo/feature/image slots, and a CardGroup that owns stacked, inline, and grid layouts plus 2-D proximity-hover highlighting.", "dependencies": [ "framer-motion" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/use-proximity-hover.json" ], "files": [ { "path": "src/components/ui/card.tsx", "content": "\"use client\";\n\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useMemo,\n useRef,\n type HTMLAttributes,\n type ReactElement,\n type ReactNode,\n} from \"react\";\nimport Link from \"next/link\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { useIcon, type IconComponent } from \"@/lib/icon-context\";\nimport { useProximityHover } from \"@/hooks/use-proximity-hover\";\n\n// ---------------------------------------------------------------------------\n// Card is shadcn/ui's compositional card — the same parts and `data-slot`\n// contract (Card, CardHeader, CardTitle, CardDescription, CardAction,\n// CardContent, CardFooter) — with the Zeron Design layer on top:\n// design tokens, a weight-animated title, and a sibling CardGroup that owns\n// layout (stacked list, inline rows, or grid) plus the magnetic proximity\n// highlight that previews where a click will land.\n//\n// Unlike stock shadcn, the surface is transparent and borderless by default:\n// cards inherit whatever substrate their parent provides (see the Surfaces\n// system) and lean on hairline dividers / the proximity highlight rather than\n// a drawn frame. A Card renders fine on its own; inside a CardGroup it\n// registers itself so the group's highlight can find it.\n// ---------------------------------------------------------------------------\n\ntype CardOrientation = \"card\" | \"inline\";\ntype CardBorder = \"none\" | \"outlined\";\n\n// ── Group context ────────────────────────────────────────\n\ninterface CardGroupContextValue {\n registerItem: (index: number, element: HTMLElement | null) => void;\n activeIndex: number | null;\n /** Index of the persistently-selected card, or -1. Its neighbours drop the\n * hairline that would otherwise cut across the selection fill. */\n selectedIndex: number;\n orientation: CardOrientation;\n columns: number;\n count: number;\n /** Individual cards carry their own border/tile shape (separated grids). */\n separated: boolean;\n /** Inner hairline dividers are drawn between adjacent cards. */\n divided: boolean;\n outlined: boolean;\n}\n\nconst CardGroupContext = createContext(null);\n\n// ── Per-card context ─────────────────────────────────────\n// Lets the compositional parts (title, header, footer…) adapt to the card\n// they're inside without threading props: the title reads `emphasized` to\n// animate its weight; parts read `orientation` to switch padding/flow.\n\ninterface CardContextValue {\n emphasized: boolean;\n orientation: CardOrientation;\n clickable: boolean;\n /** An inline card holding a full CardImage centres its text + actions in a\n * column beside the image, so the footer drops below the text (hugged and\n * vertically centred) instead of trailing to the right. */\n hasImage: boolean;\n}\n\nconst CardContext = createContext({\n emphasized: false,\n orientation: \"card\",\n clickable: false,\n hasImage: false,\n});\n\n// ── CardGroup ────────────────────────────────────────────\n\ninterface CardGroupProps extends Omit, \"onDrag\"> {\n /** How each card lays its own content out.\n * \"card\" — stacked vertically (media/header on top). \"inline\" — a\n * horizontal row (leading media, trailing footer), like a Table row.\n * @default \"card\" */\n orientation?: CardOrientation;\n /** Number of grid columns. >1 enables 2-D proximity across rows and columns.\n * @default 1 */\n columns?: number;\n /** \"none\" — borderless (default), separated only by subtle dividers.\n * \"outlined\" — draws a border: one shared frame when grouped, or one per\n * card when `separated`. @default \"none\" */\n border?: CardBorder;\n /** Split the group into individually-shaped cards with a gap between them\n * (a grid of tiles) instead of one continuous divided block. @default false */\n separated?: boolean;\n /** Enable the magnetic proximity-hover highlight. @default true */\n proximityHover?: boolean;\n}\n\nconst CardGroup = forwardRef(\n (\n {\n orientation = \"card\",\n columns = 1,\n border = \"none\",\n separated = false,\n proximityHover = true,\n className,\n children,\n ...props\n },\n ref\n ) => {\n const containerRef = useRef(null);\n const shape = useShape();\n\n // >1 column wraps into a grid, where nearest-item must be resolved in two\n // dimensions; a single column is a plain vertical list.\n const axis = columns > 1 ? \"xy\" : \"y\";\n const {\n activeIndex,\n itemRects,\n sessionRef,\n handlers,\n registerItem,\n measureItems,\n } = useProximityHover(containerRef, { axis });\n\n // Assign each valid child a stable proximity index so callers never thread\n // one through by hand (Table asks for it; here the group owns it).\n const childArray = Children.toArray(children).filter(isValidElement);\n const count = childArray.length;\n const indexed = childArray.map((child, i) =>\n cloneElement(child as ReactElement<{ index?: number }>, { index: i })\n );\n // Which card is selected — so its neighbours can drop the divider that\n // would otherwise slice through the selection fill.\n const selectedIndex = childArray.findIndex(\n (child) => (child.props as { selected?: boolean }).selected\n );\n\n useEffect(() => {\n measureItems();\n }, [measureItems, count, columns, orientation, separated, border]);\n\n const outlined = border === \"outlined\";\n const divided = !separated;\n\n const contextValue = useMemo(\n () => ({\n registerItem,\n activeIndex,\n selectedIndex,\n orientation,\n columns,\n count,\n separated,\n divided,\n outlined,\n }),\n [\n registerItem,\n activeIndex,\n selectedIndex,\n orientation,\n columns,\n count,\n separated,\n divided,\n outlined,\n ]\n );\n\n const activeRect =\n proximityHover && activeIndex !== null ? itemRects[activeIndex] : null;\n\n return (\n \n {\n (containerRef as React.MutableRefObject).current =\n node;\n if (typeof ref === \"function\") ref(node);\n else if (ref)\n (ref as React.MutableRefObject).current = node;\n }}\n {...props}\n data-slot=\"card-group\"\n data-orientation={orientation}\n className={cn(\n \"relative grid\",\n // A shared frame clips the highlight + dividers to its rounded\n // corners; separated tiles clip themselves.\n outlined && !separated && `border border-border-subtle overflow-hidden ${shape.container}`,\n separated ? \"gap-2\" : \"gap-0\",\n className\n )}\n style={{\n gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,\n }}\n onMouseEnter={proximityHover ? handlers.onMouseEnter : undefined}\n onMouseMove={proximityHover ? handlers.onMouseMove : undefined}\n onMouseLeave={proximityHover ? handlers.onMouseLeave : undefined}\n >\n {/* Proximity highlight — a single magnetic layer that springs to the\n card nearest the cursor, previewing where a click will land. */}\n \n {activeRect && (\n \n )}\n \n\n {indexed}\n \n \n );\n }\n);\n\nCardGroup.displayName = \"CardGroup\";\n\n// ── Card ─────────────────────────────────────────────────\n\ninterface CardProps extends Omit, \"onClick\"> {\n /** Makes the whole card an interactive target; proximity hover previews it.\n * Renders a stretched link when `href` is set, else a stretched button. */\n onClick?: () => void;\n href?: string;\n external?: boolean;\n /** Accessible name for the stretched link/button when the whole card is\n * clickable (the card's visible title isn't wired up automatically). */\n label?: string;\n /** Persistent selected state, on top of the transient proximity hover. */\n selected?: boolean;\n disabled?: boolean;\n /** Shows a dismiss (✕) button in the corner. */\n dismissible?: boolean;\n onDismiss?: () => void;\n /** Injected by CardGroup — do not set by hand. */\n index?: number;\n}\n\nconst Card = forwardRef(\n (\n {\n onClick,\n href,\n external,\n label,\n selected = false,\n disabled = false,\n dismissible = false,\n onDismiss,\n index,\n className,\n children,\n ...props\n },\n ref\n ) => {\n const internalRef = useRef(null);\n const shape = useShape();\n const group = useContext(CardGroupContext);\n const XIcon = useIcon(\"x\");\n\n const orientation = group?.orientation ?? \"card\";\n const columns = group?.columns ?? 1;\n const count = group?.count ?? 1;\n const separated = group?.separated ?? true;\n const divided = group?.divided ?? false;\n const outlined = group?.outlined ?? false;\n const activeIndex = group?.activeIndex ?? null;\n const selectedIndex = group?.selectedIndex ?? -1;\n\n // Depend on the stable registerItem callback, not the whole group context —\n // the context object's identity changes on every proximity/selection frame,\n // which would otherwise re-register every card each frame.\n const registerItem = group?.registerItem;\n useEffect(() => {\n if (index === undefined || !registerItem) return;\n registerItem(index, internalRef.current);\n return () => registerItem(index, null);\n }, [index, registerItem]);\n\n // Divider geometry: draw a hairline toward the neighbour below / to the\n // right, but drop it next to the active OR selected card so the highlight\n // and selection fill read clean (the same trick Table uses on row borders).\n const col = index !== undefined ? index % columns : 0;\n const hasBelow = index !== undefined && index + columns < count;\n const hasRight =\n index !== undefined && col < columns - 1 && index + 1 < count;\n const self = index ?? -1;\n const touchesBelow = (i: number) => i === self || i === self + columns;\n const touchesRight = (i: number) => i === self || i === self + 1;\n const showBottom =\n divided &&\n hasBelow &&\n !(touchesBelow(activeIndex ?? -1) || touchesBelow(selectedIndex));\n const showRight =\n divided &&\n hasRight &&\n !(touchesRight(activeIndex ?? -1) || touchesRight(selectedIndex));\n\n const isInline = orientation === \"inline\";\n // An inline card with a full-bleed image reflows so its actions stack under\n // the text (see the body wrapper below + CardHeader/CardFooter). Match the\n // image child by identity OR displayName so detection and the split below\n // agree even when module identity drifts (e.g. HMR duplication).\n const isCardImage = (child: ReactNode) =>\n isValidElement(child) &&\n (child.type === CardImage ||\n (child.type as { displayName?: string })?.displayName === \"CardImage\");\n const hasImage = Children.toArray(children).some(isCardImage);\n const inlineImage = isInline && hasImage;\n const clickable = !!href || !!onClick;\n // Title weight follows the persistent selected state only — proximity hover\n // previews via the highlight fill, not by bolding the label.\n const emphasized = selected;\n\n // A standalone card (no group) is its own tile — always rounded + clipped.\n // Inside a group, a separated tile carries its own rounding + clip only when\n // it draws a visible frame; a borderless separated tile has no surface to\n // hug, so it stays unclipped and its media reads as a plain square, and a\n // card in a continuous block leans on the shared group frame for both.\n const tileShape = !group\n ? cn(shape.container, \"overflow-hidden\")\n : separated && outlined\n ? cn(shape.container, \"overflow-hidden border border-border-subtle\")\n : \"\";\n\n // Stretched overlay makes the whole card the click target while keeping\n // action buttons (higher z) independently clickable — the accessible\n // alternative to nesting interactive elements inside a button/anchor. A\n // disabled card drops the overlay entirely so it can't be tabbed to or\n // activated by keyboard (pointer-events-none only blocks the mouse).\n const overlay = clickable && !disabled ? (\n href ? (\n \n ) : (\n \n )\n ) : null;\n\n const cardContext = useMemo(\n () => ({ emphasized, orientation, clickable, hasImage }),\n [emphasized, orientation, clickable, hasImage]\n );\n\n // Inline image cards wrap their non-image parts in a centred column so the\n // title, description, and actions hug together and sit vertically centred\n // against the image instead of stretching to its full height.\n let body: ReactNode = children;\n if (inlineImage) {\n const parts = Children.toArray(children);\n const image = parts.find(isCardImage);\n const rest = parts.filter((part) => part !== image);\n body = (\n <>\n {image}\n
\n {rest}\n
\n \n );\n }\n\n return (\n \n {\n (internalRef as React.MutableRefObject).current =\n node;\n if (typeof ref === \"function\") ref(node);\n else if (ref)\n (ref as React.MutableRefObject).current = node;\n }}\n data-slot=\"card\"\n data-proximity-index={index}\n data-selected={selected || undefined}\n data-orientation={orientation}\n aria-disabled={disabled || undefined}\n className={cn(\n \"group/card relative z-content min-w-0 min-h-[60px]\",\n inlineImage\n ? // Image on the left; the text + actions ride in a centred\n // column beside it (see the wrapper in the body below).\n \"flex flex-row items-center gap-3\"\n : isInline\n ? \"flex flex-row items-center gap-3 pl-4\"\n : \"flex flex-col pb-4\",\n // Standalone (no group) cards can't lean on the group highlight, so\n // they carry their own hover tint when interactive.\n !group && clickable && !disabled && \"transition-colors duration-fast hover:bg-hover\",\n tileShape,\n disabled && \"opacity-50 pointer-events-none\",\n className\n )}\n {...props}\n >\n {/* Persistent selected fill + dividers sit behind the static content\n (z-underlay); the stretched overlay (z-raised) sits above content so the\n whole card is clickable, and actions/dismiss (z-action) rise above the\n overlay to stay independently interactive. */}\n {selected && (\n \n )}\n\n {/* Dividers between borderless neighbours. Where both hairlines meet,\n the vertical one stops 1px short so the horizontal hairline owns\n the crossing pixel — two 60% lines would otherwise stack there and\n read brighter than the rest of the grid. */}\n {showBottom && (\n \n )}\n {showRight && (\n \n )}\n\n {overlay}\n\n {body}\n\n {/* Dismiss control sits above the stretched overlay. */}\n {dismissible && (\n \n \n \n )}\n \n \n );\n }\n);\n\nCard.displayName = \"Card\";\n\n// ── CardHeader ───────────────────────────────────────────\n// shadcn's header grid: title + description stack, with CardAction pinned to\n// the top-right column. In an inline card it becomes the flexible text column\n// between the leading media and trailing footer.\n\nconst CardHeader = forwardRef>(\n ({ className, ...props }, ref) => {\n const { orientation, hasImage } = useContext(CardContext);\n const inlineImage = orientation === \"inline\" && hasImage;\n return (\n \n );\n }\n);\n\nCardHeader.displayName = \"CardHeader\";\n\n// ── CardTitle ────────────────────────────────────────────\n\nconst CardTitle = forwardRef>(\n ({ className, children, ...props }, ref) => {\n const { emphasized } = useContext(CardContext);\n // Ghost-span pattern: an invisible semibold copy reserves the width so the\n // resting→active weight animation never reflows the row.\n return (\n \n \n {children}\n \n \n {children}\n \n \n );\n }\n);\n\nCardTitle.displayName = \"CardTitle\";\n\n// ── CardDescription ──────────────────────────────────────\n\nconst CardDescription = forwardRef<\n HTMLParagraphElement,\n HTMLAttributes\n>(({ className, ...props }, ref) => (\n \n));\n\nCardDescription.displayName = \"CardDescription\";\n\n// ── CardAction ───────────────────────────────────────────\n// Pinned to the header's top-right column (shadcn's slot). Sits above the\n// stretched overlay so any control inside stays independently clickable.\n\nconst CardAction = forwardRef>(\n ({ className, ...props }, ref) => (\n \n )\n);\n\nCardAction.displayName = \"CardAction\";\n\n// ── CardContent ──────────────────────────────────────────\n\nconst CardContent = forwardRef>(\n ({ className, ...props }, ref) => {\n const { orientation } = useContext(CardContext);\n return (\n \n );\n }\n);\n\nCardContent.displayName = \"CardContent\";\n\n// ── CardFooter ───────────────────────────────────────────\n// Actions row. Rises above the stretched overlay (z-action) so buttons stay\n// clickable. In an inline card it becomes the trailing, right-aligned slot.\n\nconst CardFooter = forwardRef>(\n ({ className, ...props }, ref) => {\n const { orientation, hasImage } = useContext(CardContext);\n const inlineImage = orientation === \"inline\" && hasImage;\n return (\n \n );\n }\n);\n\nCardFooter.displayName = \"CardFooter\";\n\n// ── CardMedia ────────────────────────────────────────────\n// FF addition: a leading icon or brand logo(s). Not part of shadcn's anatomy,\n// but the connective tissue most product cards need. A tuple renders a\n// connected logo pair (e.g. a trigger — target).\n\ntype CardLogo = string | [string, string];\n\ninterface CardMediaProps {\n logo?: CardLogo;\n logoAlt?: string;\n icon?: IconComponent;\n size?: number;\n className?: string;\n}\n\nfunction CardMedia({ logo, logoAlt, icon: Icon, size = 22, className }: CardMediaProps) {\n const { orientation } = useContext(CardContext);\n const shape = useShape();\n // Stacked: sits in the header grid; add an extra 8px so the gap below the\n // icon reads 12px (header gap-1 + mb-2). Inline: leading slot — the card owns\n // the left inset, so no extra padding here.\n const wrap = cn(orientation === \"inline\" ? \"\" : \"mb-2\", className);\n\n if (logo) {\n const logos = Array.isArray(logo) ? logo : [logo];\n return (\n \n {logos.map((src, i) => (\n \n {i > 0 && }\n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n \n ))}\n \n );\n }\n if (Icon) {\n // The icon sits in a 32×32 tinted tile so it reads as a media slot rather\n // than a bare glyph. The tile is an overlay tint (not a solid surface) so it\n // blends over whatever is behind it — the substrate or the hover highlight.\n return (\n \n \n \n );\n }\n return null;\n}\n\n// ── CardImage ────────────────────────────────────────────\n// FF addition: the prominent, full-bleed image (distinct from a small logo).\n// Stacked → a top banner; inline → a full-height leading image on the left that\n// bleeds past the card's left inset to sit flush against the edge.\n\ninterface CardImageProps {\n src: string;\n alt?: string;\n className?: string;\n}\n\nfunction CardImage({ src, alt, className }: CardImageProps) {\n const { orientation } = useContext(CardContext);\n const shape = useShape();\n return (\n // eslint-disable-next-line @next/next/no-img-element\n \n );\n}\n\n// A stable marker so the Card can recognise an image child by name, surviving\n// module-identity mismatches (e.g. HMR duplication) that break `type ===`.\nCardImage.displayName = \"CardImage\";\n\n// ── CardEyebrow ──────────────────────────────────────────\n// Small uppercase label above the title (e.g. \"New Model\").\n\nconst CardEyebrow = forwardRef>(\n ({ className, ...props }, ref) => (\n \n )\n);\n\nCardEyebrow.displayName = \"CardEyebrow\";\n\n// ── CardFeature ──────────────────────────────────────────\n// FF addition: an icon + title + description row, for feature lists inside\n// CardContent.\n\ninterface CardFeatureProps {\n icon?: IconComponent;\n title: string;\n description?: string;\n}\n\nfunction CardFeature({ icon: Icon, title, description }: CardFeatureProps) {\n return (\n
\n {Icon && (\n \n )}\n
\n \n {title}\n \n {description && (\n \n {description}\n \n )}\n
\n
\n );\n}\n\n// ── CardButton ───────────────────────────────────────────\n// Self-contained action button for the footer (keeps Card free of a Button\n// dependency, so it installs standalone). Renders an anchor when `href` is set.\n\ntype CardButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"link\";\n\nconst CARD_BUTTON_VARIANTS: Record = {\n primary: \"bg-brand text-fg-on-brand hover:bg-brand-hover active:bg-brand-active\",\n secondary: \"bg-secondary-action text-fg-default hover:bg-secondary-action-hover active:bg-secondary-action-active\",\n ghost: \"text-fg-muted hover:text-fg-default hover:bg-hover active:bg-active\",\n link: \"text-fg-default underline-offset-4 hover:underline !px-0 !h-auto\",\n};\n\ninterface CardButtonProps {\n children: React.ReactNode;\n onClick?: () => void;\n href?: string;\n variant?: CardButtonVariant;\n icon?: IconComponent;\n iconPosition?: \"start\" | \"end\";\n /** Opens the href in a new tab and appends an outward arrow glyph. */\n external?: boolean;\n disabled?: boolean;\n}\n\nfunction CardButton({\n children,\n onClick,\n href,\n variant = \"ghost\",\n icon: Icon,\n iconPosition,\n external = false,\n disabled = false,\n}: CardButtonProps) {\n const shape = useShape();\n const ArrowRight = useIcon(\"arrow-right\");\n const position = iconPosition ?? (external ? \"end\" : \"start\");\n\n const glyph = Icon ? (\n \n ) : null;\n const externalGlyph = external ? (\n \n ) : null;\n\n const inner = (\n <>\n {position === \"start\" && glyph}\n {children}\n {position === \"end\" && glyph}\n {externalGlyph}\n \n );\n\n const classes = cn(\n \"group/action relative z-action inline-flex items-center justify-center gap-1.5 h-control-xs px-2.5 text-label cursor-pointer outline-none\",\n \"transition-colors duration-fast\",\n \"focus-visible:ring-1 focus-visible:ring-focus-ring\",\n \"disabled:opacity-50 disabled:pointer-events-none\",\n shape.button,\n CARD_BUTTON_VARIANTS[variant]\n );\n\n if (href) {\n return (\n \n {inner}\n \n );\n }\n\n return (\n \n {inner}\n \n );\n}\n\nexport {\n Card,\n CardGroup,\n CardHeader,\n CardTitle,\n CardDescription,\n CardAction,\n CardContent,\n CardFooter,\n CardMedia,\n CardImage,\n CardEyebrow,\n CardFeature,\n CardButton,\n};\nexport type {\n CardProps,\n CardGroupProps,\n CardLogo,\n CardButtonProps,\n CardButtonVariant,\n};\n", "type": "registry:ui", "target": "components/ui/card.tsx" } ], "type": "registry:ui" }