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 { 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>
);
}