Cards

Product Card

An image, meta, and price card whose image zooms slightly on hover behind a clipped frame.

Install

npx shadcn@latest add @paragon/product-card

product-card.tsx

import * as React from "react";
import { Box } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ProductCardProps extends React.ComponentProps<"div"> {
  /** Product name, shown beside the price. */
  title?: string;
  /** Short meta line under the title — category, billing cadence, seats. */
  meta?: string;
  /** Optional supporting copy under the meta line. */
  description?: string;
  /** Price label, rendered in tabular-nums. Pass null to hide. */
  price?: React.ReactNode;
  /** Image URL for the media frame. */
  imageSrc?: string;
  /** Alt text for the image; falls back to the title. */
  imageAlt?: string;
  /** Custom media node — takes precedence over imageSrc. */
  image?: React.ReactNode;
  /** Disables the hover zoom where the motion would distract. */
  static?: boolean;
}

/**
 * An image, meta, and price card. The media sits behind a clipped frame
 * (overflow-hidden, concentric radius) and zooms 1 → 1.05 on hover — gated
 * behind (hover: hover) and (pointer: fine) so touch devices never trigger
 * it — while the surface's depth border deepens. Price renders in
 * tabular-nums so adjacent cards align digit-for-digit.
 */
export function ProductCard({
  title = "Advanced Analytics",
  meta = "Workspace add-on · Billed monthly",
  description,
  price = "$49",
  imageSrc,
  imageAlt,
  image,
  static: isStatic = false,
  className,
  children,
  ...props
}: ProductCardProps) {
  const media =
    image ??
    (imageSrc ? (
      <img
        src={imageSrc}
        alt={imageAlt ?? title}
        className="size-full object-cover"
      />
    ) : (
      // Neutral fallback art so the zero-props render holds up in both themes.
      <div
        aria-hidden
        className="relative flex size-full items-center justify-center bg-gradient-to-br from-secondary/80 to-muted dark:from-secondary/50 dark:to-secondary/15"
      >
        <div
          className="pointer-events-none absolute inset-0 opacity-40"
          style={{
            backgroundImage:
              "linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)",
            backgroundSize: "22px 22px",
            maskImage:
              "radial-gradient(ellipse at center, black 30%, transparent 78%)",
          }}
        />
        <div className="relative flex size-11 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-border">
          <Box className="size-5" />
        </div>
      </div>
    ));

  return (
    <div
      data-slot="product-card"
      className={cn(
        "group/product-card w-full max-w-sm rounded-xl bg-card p-2 shadow-border transition-[box-shadow] duration-(--duration-quick) ease-(--ease-out) hover:shadow-border-hover",
        className,
      )}
      {...props}
    >
      {/* Clipped frame — concentric radius: outer 14px = inner 6px + 8px padding. */}
      <div className="relative aspect-[4/3] overflow-hidden rounded-sm bg-muted">
        <div
          className={cn(
            "absolute inset-0 transition-[transform] duration-(--duration-base) ease-(--ease-out)",
            !isStatic &&
              "[@media(hover:hover)_and_(pointer:fine)_and_(prefers-reduced-motion:no-preference)]:group-hover/product-card:scale-105",
          )}
        >
          {media}
        </div>
      </div>
      <div className="px-2 pt-3 pb-2">
        <div className="flex items-baseline justify-between gap-3">
          <h3 className="truncate text-sm font-medium">{title}</h3>
          {price != null && (
            <p className="shrink-0 text-sm font-medium tabular-nums">{price}</p>
          )}
        </div>
        {meta && (
          <p className="mt-0.5 text-[13px] text-muted-foreground">{meta}</p>
        )}
        {description && (
          <p className="mt-2 text-[13px] leading-relaxed text-pretty text-muted-foreground">
            {description}
          </p>
        )}
        {children}
      </div>
    </div>
  );
}