channel3
componentproduct-cardsource: ok · hit
Product Card
Borderless, image-forward product tile with hover image-swap and color swatches, for grids and carousels. Includes a loading skeleton.
npx shadcn add "https://ui.trychannel3.com/r/product-card.json"free
Interaction
carousel
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
badgeskeletonhttps://ui.trychannel3.com/r/format.jsonhttps://ui.trychannel3.com/r/variants.json
Files1 · source cached 10/8/2026, 9:25:19 PM
registry/default/components/product-card.tsx
import * as React from "react";
import { ImageOff } from "lucide-react";
import type { OptionValue, Product } from "@channel3/sdk/resources";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
formatCurrency,
formatPrice,
isOnSale,
isSoldOut,
leadOffer,
pickHoverImage,
pickImage,
productImageUrl,
} from "@/registry/default/lib/format";
import { swatchOption } from "@/registry/default/lib/variants";
const MAX_SWATCHES = 10;
export interface ProductCardProps extends Omit<React.ComponentProps<"div">, "onSelect"> {
product: Product;
/**
* Destination URL for the card. When set, the image and title render as a real
* `<a href>` (crawlable, middle/cmd-clickable); a plain left-click still calls
* {@link ProductCardProps.onSelect} for SPA navigation.
*/
href?: string;
onSelect?: (product: Product) => void;
/**
* Called when the card is hovered, focused, or touched — before activation.
* Router-agnostic prefetch hook: wire it to your framework's route preloader
* (e.g. TanStack Router `preloadRoute`, Next.js `router.prefetch`) so the
* destination is warm by the time the user clicks. Fires at most once per
* pointer entry/focus.
*/
onPreload?: (product: Product) => void;
/**
* Called when a color swatch is clicked. Navigate to `value.product_id` (the
* variant's own product) when set. Falls back to {@link ProductCardProps.onSelect}.
*/
onSelectVariant?: (value: OptionValue) => void;
showSwatches?: boolean;
priority?: boolean;
locale?: string;
}
export function ProductCard({
product,
href,
onSelect,
onPreload,
onSelectVariant,
showSwatches = true,
priority = false,
locale,
className,
...props
}: ProductCardProps) {
const [imageFailed, setImageFailed] = React.useState(false);
const [imageLoaded, setImageLoaded] = React.useState(false);
const [useRaw, setUseRaw] = React.useState(false);
const [preview, setPreview] = React.useState<string | null>(null);
const [brokenPreview, setBrokenPreview] = React.useState<string | null>(null);
const [imageHovered, setImageHovered] = React.useState(false);
const [brokenHover, setBrokenHover] = React.useState<string | null>(null);
const cardId = React.useId();
const activePreview = preview && preview !== brokenPreview ? preview : null;
// Only the image under the pointer shows its secondary shot. Entering one
// image clears any other that missed mouseleave.
React.useEffect(() => {
const onHover = (event: Event) => {
const activeId = (event as CustomEvent<string>).detail;
if (activeId !== cardId) setImageHovered(false);
};
window.addEventListener("channel3-product-card-hover", onHover);
return () => window.removeEventListener("channel3-product-card-hover", onHover);
}, [cardId]);
// A server-rendered image can finish decoding before hydration, so `onLoad`
// never fires on the client — reveal it on mount if it's already complete.
const revealIfComplete = React.useCallback((node: HTMLImageElement | null) => {
if (node?.complete) {
setImageLoaded(true);
}
}, []);
const image = pickImage(product.images);
const imageSrc = image
? productImageUrl(image, { preferCleaned: !useRaw })
: null;
const secondImage = pickHoverImage(product.images, { excludeUrl: image?.url });
const hoverSrc = secondImage ? productImageUrl(secondImage, { preferCleaned: true }) : null;
const distinctHover = hoverSrc && hoverSrc !== imageSrc ? hoverSrc : null;
const activeHover =
imageHovered && distinctHover && distinctHover !== brokenHover ? distinctHover : null;
const brand = product.brands?.[0]?.name;
const offer = leadOffer(product.offers);
const soldOut = isSoldOut(product.offers);
const interactive = typeof onSelect === "function" || typeof onSelectVariant === "function";
const option = showSwatches && product.variants ? swatchOption(product.variants) : undefined;
const swatchValues = (option?.values ?? []).filter((value) => value.thumbnail_url);
const visibleSwatches = swatchValues.slice(0, MAX_SWATCHES);
const overflowSwatches = swatchValues.length - visibleSwatches.length;
const onSwatch = (value: OptionValue) => {
if (onSelectVariant) {
onSelectVariant(value);
} else {
onSelect?.(product);
}
};
const displayedSrc = activePreview ?? activeHover ?? imageSrc;
const media = (
<div
className="relative aspect-square overflow-hidden rounded-md bg-muted"
onMouseEnter={() => {
setImageHovered(true);
window.dispatchEvent(
new CustomEvent("channel3-product-card-hover", { detail: cardId }),
);
}}
onMouseLeave={() => setImageHovered(false)}
>
{displayedSrc && !imageFailed ? (
<img
src={displayedSrc}
alt={activePreview || activeHover ? "" : (image?.alt_text ?? "")}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : undefined}
decoding="async"
className={cn(
"size-full object-cover transition duration-300",
imageLoaded || activePreview || activeHover ? "opacity-100" : "opacity-0",
!activePreview && !distinctHover && "hover:scale-105",
)}
ref={revealIfComplete}
onLoad={() => setImageLoaded(true)}
onError={() => {
if (activePreview) {
setBrokenPreview(activePreview);
return;
}
if (activeHover) {
setBrokenHover(activeHover);
return;
}
if (!useRaw && image?.cleaned_url && image.cleaned_url !== image.url) {
setUseRaw(true);
setImageLoaded(false);
return;
}
setImageFailed(true);
}}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-8" aria-hidden />
</div>
)}
{soldOut ? (
<Badge variant="secondary" className="absolute right-2 top-2">
Sold out
</Badge>
) : null}
</div>
);
const fallbackThumb = imageSrc && !imageFailed ? imageSrc : null;
const thumbnails = showSwatches ? (
<div className="flex flex-wrap items-center gap-1 pt-2.5">
{visibleSwatches.length > 0 ? (
<>
{visibleSwatches.map((value) => (
<button
key={value.label}
type="button"
title={value.label}
aria-label={value.label}
onClick={() => onSwatch(value)}
onMouseEnter={() => setPreview(value.thumbnail_url ?? null)}
onMouseLeave={() => setPreview(null)}
onFocus={() => setPreview(value.thumbnail_url ?? null)}
onBlur={() => setPreview(null)}
className="size-5 cursor-pointer overflow-hidden rounded-full ring-1 ring-border transition hover:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<img src={value.thumbnail_url ?? undefined} alt="" className="size-full object-cover" />
</button>
))}
{overflowSwatches > 0 ? (
<span className="text-xs text-muted-foreground">{`+${overflowSwatches}`}</span>
) : null}
</>
) : (
<button
type="button"
aria-label={product.title}
onClick={() => onSelect?.(product)}
className="flex size-5 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-1 ring-border transition hover:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{fallbackThumb ? (
<img src={fallbackThumb} alt="" className="size-full object-cover" />
) : (
<ImageOff className="size-2.5" aria-hidden />
)}
</button>
)}
</div>
) : null;
const meta = (
<div className="flex flex-col gap-1 pt-2.5">
{brand ? <span className="truncate text-xs text-muted-foreground">{brand}</span> : null}
<span className="truncate text-sm leading-snug font-medium">{product.title}</span>
<div className="flex min-h-[1.5rem] items-baseline gap-2 pt-1">
{offer ? (
<>
<span className="text-sm font-semibold">{formatPrice(offer.price, locale)}</span>
{isOnSale(offer.price) && offer.price.compare_at_price ? (
<span className="text-xs text-muted-foreground line-through">
{formatCurrency(offer.price.compare_at_price, offer.price.currency, locale)}
</span>
) : null}
</>
) : null}
</div>
</div>
);
const tapClass =
"block w-full cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const preload = onPreload ? () => onPreload(product) : undefined;
// Real href stays crawlable; a plain left-click is SPA via onSelect.
const tap = (children: React.ReactNode) => {
if (href) {
return (
<a
href={href}
className={tapClass}
onMouseEnter={preload}
onFocus={preload}
onTouchStart={preload}
onClick={(event) => {
if (
onSelect &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
onSelect(product);
}
}}
>
{children}
</a>
);
}
if (interactive) {
return (
<button
type="button"
onClick={() => onSelect?.(product)}
onMouseEnter={preload}
onFocus={preload}
onTouchStart={preload}
className={tapClass}
>
{children}
</button>
);
}
return children;
};
return (
<div
data-slot="product-card"
className={cn("isolate flex h-full flex-col", className)}
{...props}
>
{tap(media)}
{thumbnails}
{tap(meta)}
</div>
);
}
export function ProductCardSkeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="product-card-skeleton" className={cn("flex h-full flex-col", className)} {...props}>
<Skeleton className="aspect-square w-full rounded-md" />
<div className="flex flex-col gap-2 pt-3">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="mt-1 h-4 w-1/4" />
</div>
</div>
);
}