Patterns
PatternsTier 5 · Blocks
Product card
The marketplace tile: image, maker, price, rating, wishlist.
bunx --bun shadcn@latest add @woohl/product-cardPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/product-cardregistry/tier-5-blocks/product-card.tsx
"use client";
import type { ElementType, ReactNode } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { WoohlImage } from "@/registry/tier-1-tokens/imagery/woohl-image";
import { Badge } from "@/registry/tier-3-components/badge";
import { Price } from "@/registry/tier-5-blocks/price";
import { Rating } from "@/registry/tier-5-blocks/rating";
import { WishlistButton } from "@/registry/tier-5-blocks/wishlist-button";
/**
* ProductCard.
*
* The most-repeated object on Woohl, and the reason the patterns layer exists:
* image, maker, title, rating, price, wishlist, badge — composed once, so a
* category listing, a search result, a bundle and a "you might also like" rail
* are visibly the same product, not four interpretations of one.
*
* THE MAKER'S NAME COMES FIRST
* Above the product title, not below it in grey. Woohl's whole claim is
* "made by people you can name" — a card that leads with the object and
* whispers the maker is a card for a different marketplace.
*
* WHY THE WHOLE CARD ISN'T ONE LINK
* The wishlist button lives inside the card. Nesting a button inside a link
* is invalid HTML and behaves unpredictably. Instead the TITLE is the link
* and it carries a stretched hit area over the card, so a click anywhere
* navigates while the heart stays independently clickable.
*/
export type ProductCardProps = {
href: string;
name: string;
maker: string;
price: number;
compareAt?: number;
image?: string;
/** What is happening in the photograph. Falls back to a described product. */
imageAlt?: string;
rating?: number;
reviewCount?: number;
/** "New in", "Made to order", "Ships in 48h". One, never two. */
badge?: string;
saved?: boolean;
onToggleSave?: () => void;
/** Router link component. `as={Link}` in a Next app. */
as?: ElementType;
/** Extra content under the price — a delivery estimate, a stock warning. */
footer?: ReactNode;
className?: string;
};
export function ProductCard({
href,
name,
maker,
price,
compareAt,
image,
imageAlt,
rating,
reviewCount,
badge,
saved,
onToggleSave,
as: LinkComponent = "a",
footer,
className,
}: ProductCardProps) {
return (
<article
data-slot="product-card"
className={cn(
"group relative isolate flex flex-col overflow-hidden rounded-xl",
"border-border-subtle bg-surface-raised shadow-e1 border",
"motion-base transition-[box-shadow,transform,border-color]",
"hover:border-border-strong hover:shadow-e2 hover:-translate-y-0.5",
className,
)}
>
<div className="relative">
<WoohlImage
src={image ?? ""}
alt={imageAlt ?? `${name} by ${maker}`}
ratio="square"
radius="none"
className="rounded-none"
/>
{badge && (
<span className="z-raised absolute top-3 left-3">
<Badge tone="ink" size="sm">
{badge}
</Badge>
</span>
)}
{onToggleSave && (
<span className="z-raised absolute top-2.5 right-2.5">
<WishlistButton
saved={Boolean(saved)}
onToggle={onToggleSave}
productName={name}
size="sm"
/>
</span>
)}
</div>
<div className="font-ui flex flex-1 flex-col p-4">
<p className="text-caption text-text-muted">{maker}</p>
<h3 className="text-body text-text-primary mt-0.5 leading-[1.3] font-bold">
<LinkComponent
href={href}
/* The stretched link: the whole card is clickable, and the heart —
which sits above it on the z-axis — still isn't swallowed. */
className="after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
>
{name}
</LinkComponent>
</h3>
{rating !== undefined && (
<div className="mt-2">
<Rating value={rating} count={reviewCount} size={14} />
</div>
)}
<div className="mt-2.5">
<Price amount={price} compareAt={compareAt} />
</div>
{footer && (
<div className="text-caption text-text-muted mt-2">{footer}</div>
)}
</div>
</article>
);
}
/**
* The loading state.
*
* Shaped like the card it replaces — same ratio, same rows — so the layout
* doesn't jump when the data lands. A spinner in a grid slot tells you
* something is coming; a skeleton tells you what.
*/
export function ProductCardSkeleton({ className }: { className?: string }) {
return (
<div
data-slot="product-card-skeleton"
aria-hidden
className={cn(
"border-border-subtle bg-surface-raised overflow-hidden rounded-xl border",
className,
)}
>
<div
className="animate-woohl-shimmer bg-surface-sunken aspect-square"
data-woohl-motion="loop"
/>
<div className="flex flex-col gap-2 p-4">
<Shimmer className="h-3 w-1/3" />
<Shimmer className="h-4 w-4/5" />
<Shimmer className="h-4 w-1/4" />
</div>
</div>
);
}
function Shimmer({ className }: { className?: string }) {
return (
<span
data-woohl-motion="loop"
className={cn(
"animate-woohl-shimmer block rounded-sm bg-[length:400px_100%]",
"bg-[linear-gradient(90deg,var(--surface-sunken)_0%,var(--border-subtle)_40%,var(--surface-sunken)_80%)]",
className,
)}
/>
);
}
ProductCardProps
| Prop | Type | Default |
|---|---|---|
href* | string | — |
maker* | string | — |
name* | string | — |
price* | number | — |
asRouter link component. `as={Link}` in a Next app. | ElementType | — |
badge"New in", "Made to order", "Ships in 48h". One, never two. | string | — |
className | string | — |
compareAt | number | — |
footerExtra content under the price — a delivery estimate, a stock warning. | ReactNode | — |
image | string | — |
imageAltWhat is happening in the photograph. Falls back to a described product. | string | — |
onToggleSave | () => void | — |
rating | number | — |
reviewCount | number | — |
saved | boolean | — |
Registry items
npm packages
None beyond React itself.