Product Card

Compound card for commerce rows — image well and a title/subtitle/metric footer

Twemco Clock

Twemco Clock

Clock

$219
import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardMetric,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
export default function ProductCardDemo() {
return (
<ProductCard>
<ProductCardImage src="/images/products/twemco-clock.png" alt="Twemco Clock" />
<ProductCardContent>
<ProductCardHeader>
<ProductCardTitle>Twemco Clock</ProductCardTitle>
<ProductCardSubtitle>Clock</ProductCardSubtitle>
</ProductCardHeader>
<ProductCardMetric>$219</ProductCardMetric>
</ProductCardContent>
</ProductCard>
);
}

Installation

npx shadcn@latest add https://www.deltacomponents.dev/r/product-card.json

Usage

1import {
2 ProductCard,
3 ProductCardContent,
4 ProductCardHeader,
5 ProductCardMetric,
6 ProductCardSubtitle,
7 ProductCardTitle,
8} from "@/components/ui/product-card";
9
10<ProductCard>
11 <ProductCardContent>
12 <ProductCardHeader>
13 <ProductCardTitle>Twemco Clock</ProductCardTitle>
14 <ProductCardSubtitle>Clock</ProductCardSubtitle>
15 </ProductCardHeader>
16 <ProductCardMetric>$219</ProductCardMetric>
17 </ProductCardContent>
18</ProductCard>

Basic

Twemco Clock

Twemco Clock

Clock

$219
import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardMetric,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
export default function ProductCardBasic() {
return (
<ProductCard>
<ProductCardImage src="/images/products/twemco-clock.png" alt="Twemco Clock" />
<ProductCardContent>
<ProductCardHeader>
<ProductCardTitle>Twemco Clock</ProductCardTitle>
<ProductCardSubtitle>Clock</ProductCardSubtitle>
</ProductCardHeader>
<ProductCardMetric>$219</ProductCardMetric>
</ProductCardContent>
</ProductCard>
);
}

Inner layout

Twemco Clock

Twemco Clock

Clock

$219
import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardMetric,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
export default function ProductCardInner() {
return (
<ProductCard variant="inner">
<ProductCardImage src="/images/products/twemco-clock.png" alt="Twemco Clock">
<ProductCardContent>
<ProductCardHeader>
<ProductCardTitle>Twemco Clock</ProductCardTitle>
<ProductCardSubtitle>Clock</ProductCardSubtitle>
</ProductCardHeader>
<ProductCardMetric>$219</ProductCardMetric>
</ProductCardContent>
</ProductCardImage>
</ProductCard>
);
}

Sizes

Analogue Pocket

Analogue Pocket

Gaming Console

$219
import { useState } from "react";
import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardMetric,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
export default function ProductCardSizes() {
const [size, setSize] = useState<"sm" | "default" | "lg">("lg");
return (
<div className="flex w-full flex-col items-center gap-5">
<ProductCard size={size}>
<ProductCardImage src="/images/products/analogue-pocket.webp" alt="Analogue Pocket" />
<ProductCardContent>
<ProductCardHeader>
<ProductCardTitle>Analogue Pocket</ProductCardTitle>
<ProductCardSubtitle>Gaming Console</ProductCardSubtitle>
</ProductCardHeader>
<ProductCardMetric>$219</ProductCardMetric>
</ProductCardContent>
</ProductCard>
{/* A bare <select>, not the library's — the point of this demo is the
card reacting to `size`, and a native control keeps the surrounding
chrome out of the way. */}
<label className="text-caption text-muted-foreground flex items-center gap-2">
Size
<select
value={size}
onChange={(e) => setSize(e.target.value as "sm" | "default" | "lg")}
className="border-border bg-background text-foreground text-caption rounded-md border px-2 py-1 outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]"
>
<option value="sm">sm</option>
<option value="default">default</option>
<option value="lg">lg</option>

Grid

import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
/** The essay row — the images ship with the repo, and each card links out to
* the essay it is a screenshot of. */
const ESSAYS = [
{ title: "Buy Wisely", author: "Steph Ango", image: "/images/essays/buy-wisely.jpg", url: "https://stephango.com/buy-wisely" },
{ title: "On Becoming Competitive", author: "Ludwig", image: "/images/essays/on-becoming-competitive.jpg", url: "https://ludwigabap.bearblog.dev/on-becoming-competitive-when-joining-a-new-company/" },
{ title: "Salary Negotiations", author: "Patrick McKenzie", image: "/images/essays/salary-negotiations.jpg", url: "https://www.kalzumeus.com/2012/01/23/salary-negotiation/" },
{ title: "Solution Space & Taste", author: "Grant Slatton", image: "/images/essays/solution-space-taste.jpg", url: "https://grantslatton.com/solution-space-taste" },
{ title: "The Bear Manifesto", author: "Herman", image: "/images/essays/the-bear-manifesto.jpg", url: "https://herman.bearblog.dev/manifesto/" },
{ title: "Write Like You Talk", author: "Paul Graham", image: "/images/essays/write-like-you-talk.jpg", url: "https://paulgraham.com/talk.html" },
];
export default function ProductCardGrid() {
return (
<div className="grid w-full grid-cols-2 gap-5 sm:grid-cols-3">
{ESSAYS.map((essay) => (
<a
key={essay.title}
href={essay.url}
target="_blank"
rel="noopener noreferrer"
className="rounded outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)] focus-visible:ring-offset-2"
>
<ProductCard className="w-full max-w-none rounded-none">
<ProductCardImage src={essay.image} alt={essay.title} className="rounded-none" />
<ProductCardContent>
<ProductCardHeader>
<ProductCardTitle>{essay.title}</ProductCardTitle>
<ProductCardSubtitle>by {essay.author}</ProductCardSubtitle>
</ProductCardHeader>
</ProductCardContent>
</ProductCard>

Animated images

A GIF has no play control, so a card that should be still until you point at it swaps the source instead: a still frame at rest, the animation from frame one on hover. The handlers reach the well because every part of the card spreads the DOM props it is given.

Hitmontop

Hitmontop

Fighting type

#237
import { useState } from "react";
import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardMetric,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
const STILL = "/images/products/hitmontop-still.png";
const ANIMATED = "/images/products/hitmontop.gif";
export default function ProductCardAnimated() {
// A GIF has no play control, so swapping the source is the whole trick: the
// still frame rests, the animation starts from frame one on every hover.
// The handlers reach the image well because it spreads the DOM props it is
// given, like every other part of the card.
const [playing, setPlaying] = useState(false);
return (
<ProductCard>
<ProductCardImage
src={playing ? ANIMATED : STILL}
alt="Hitmontop"
// Sits the sprite in a little further than the size step's own p-8.
imageClassName="p-12"
onMouseEnter={() => setPlaying(true)}
onMouseLeave={() => setPlaying(false)}
/>
<ProductCardContent>
<ProductCardHeader>
<ProductCardTitle>Hitmontop</ProductCardTitle>
<ProductCardSubtitle>Fighting type</ProductCardSubtitle>
</ProductCardHeader>
<ProductCardMetric>#237</ProductCardMetric>
</ProductCardContent>
</ProductCard>
);

Preparing images

The best product images are ones cut out from their background.

On macOS, right-click the file in Finder and choose Quick Actions → Remove Background. If it isn't there, add it under Customize — see Apple's guide to Quick Actions in the Finder.

Override Styling

Every part takes its own className, merged last through tailwind-merge, so a utility that conflicts with a default replaces it instead of stacking with it. The card's type size is set once on the root and inherited by the title, subtitle and metric, so one text-* moves the whole block.

Twemco Clock

Twemco Clock

Desk clock

$219
import {
ProductCard,
ProductCardContent,
ProductCardHeader,
ProductCardImage,
ProductCardMetric,
ProductCardSubtitle,
ProductCardTitle,
} from "@/components/ui/product-card";
export default function ProductCardOverrideStyling() {
return (
// The same card fitted to another design system: a bordered tile with an
// inset well. The root's 8px padding and 16px corner make the well's own
// corner 8px, so the two stay concentric.
<ProductCard className="border-border/60 bg-card rounded-2xl border p-2">
<ProductCardImage
src="/images/products/twemco-clock.png"
alt="Twemco Clock"
// Corner only: the well keeps the component's own `bg-muted` ground,
// the same one every other card on this page rests on.
className="rounded-lg"
/>
<ProductCardContent className="px-1 pt-3 pb-1">
<ProductCardHeader>
<ProductCardTitle className="font-semibold tracking-tight">
Twemco Clock
</ProductCardTitle>
<ProductCardSubtitle className="text-[0.85em] tracking-wider uppercase">
Desk clock
</ProductCardSubtitle>
</ProductCardHeader>
<ProductCardMetric className="tabular-nums">$219</ProductCardMetric>
</ProductCardContent>
</ProductCard>
);
}

Parts you hold no reference to are reachable by data-slot:

size caps the card's width; a w-* or max-w-* in className overrides that cap outright, which is what the grid above relies on.

The image well's resting ground and its mouse-enter ground are two separate utilities. Passing bg-stone-200 alone leaves the component's own hover:bg-muted/80 in charge from the moment the pointer arrives, so recolour both: bg-stone-200 hover:bg-stone-300.

Accessibility

The root is a div, not a control. Passing onClick gives it a pointer cursor but no keyboard access — for a card that must be reachable by keyboard, put a real <a> or <button> on the title and stretch it over the card with after:absolute after:inset-0, the way the grid example wraps each card in a link, which nesting a control inside role="button" would not allow.

API Reference

ProductCard

ProductCardImage

ProductCardContent

ProductCardHeader

ProductCardTitle

ProductCardSubtitle

ProductCardMetric