An image, meta, and price card whose image zooms slightly on hover behind a clipped frame.
npx shadcn@latest add @paragon/product-cardimport * 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>
);
}