Product Card
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 { cn } from "@/lib/utils";

/**
 * A CSS-composed default product "photo" — a soft studio backdrop, a specular
 * key light, and a rounded product form casting a contact shadow. No network,
 * no flat block; restrained slate/indigo palette with the house accent so the
 * zero-props render reads as a real studio shot in both themes.
 */
function ProductScene() {
  return (
    <div
      aria-hidden
      className="relative flex size-full items-center justify-center overflow-hidden"
      style={{
        background:
          "radial-gradient(120% 100% at 50% 0%, oklch(0.96 0.008 255) 0%, oklch(0.9 0.012 250) 55%, oklch(0.82 0.02 250) 100%)",
      }}
    >
      {/* Warm-to-cool key light sweeping from the upper left. */}
      <div
        className="absolute inset-0"
        style={{
          background:
            "linear-gradient(125deg, oklch(1 0 0 / 0.55) 0%, transparent 42%)",
        }}
      />
      {/* Contact shadow grounding the product. */}
      <div
        className="absolute bottom-[20%] left-1/2 h-[10%] w-[46%] -translate-x-1/2 rounded-[50%] blur-md"
        style={{ background: "oklch(0.4 0.03 255 / 0.35)" }}
      />
      {/* Product body — rounded slab with a soft top-lit gradient. */}
      <div
        className="relative z-[1] h-[46%] w-[38%] -translate-y-[6%] rounded-[22%] shadow-lg"
        style={{
          background:
            "linear-gradient(155deg, #6f9bef 0%, #4d80e6 46%, #3a63c4 100%)",
        }}
      >
        {/* Screen / face inset. */}
        <div
          className="absolute inset-[14%] rounded-[20%]"
          style={{
            background:
              "linear-gradient(150deg, oklch(1 0 0 / 0.28), oklch(1 0 0 / 0.04) 60%)",
          }}
        />
        {/* Specular hotspot. */}
        <div
          className="absolute top-[10%] left-[14%] h-[26%] w-[30%] rounded-full blur-[3px]"
          style={{ background: "oklch(1 0 0 / 0.5)" }}
        />
      </div>
    </div>
  );
}

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). On hover the whole surface lifts 1px
 * and its depth border deepens (shadow-border → shadow-border-hover) — the
 * house card language: interruptible CSS transitions that retarget on rapid
 * hover, no janky media scale — gated behind (hover: hover) and (pointer:
 * fine) so touch devices never trigger it, and dropped under reduced motion.
 * 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}
        loading="lazy"
        className="size-full object-cover"
      />
    ) : (
      // CSS-composed studio shot so the zero-props render holds up in both themes.
      <ProductScene />
    ));

  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,translate] duration-(--duration-quick) ease-(--ease-out) hover:shadow-border-hover",
        !isStatic &&
          "[@media(hover:hover)_and_(pointer:fine)_and_(prefers-reduced-motion:no-preference)]:hover:-translate-y-px",
        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="absolute inset-0">{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>
  );
}