HextaUI

Product Card

The product tile for collection grids and rails, with swatches, quick add and a wishlist heart. Handles sold-out sizes, price ranges and failed adds.

Open in new tab

Product Card is the tile you put in a collection grid, search results or a recommendations rail. It takes a Product, the same model Variant Picker uses, and shows one card per product, never one per color. The whole card is a single link on the title, while the heart, swatches and quick add stay separate buttons on top of it.

Colors show as a row of small swatches, all of them reachable. On a narrow card the row scrolls sideways with the last swatch cut off and the edge faded, so shoppers can tell there are more. Hovering a swatch previews that color’s photo after a short pause and preloads it, so the crossfade never shows a blank frame. Clicking it picks the color: the price, badges and stock follow it, and the link opens the product page with ?color= set. On a mouse, hovering the card crossfades to a second photo.

Quick add fits the product. A product with one variant gets an Add to bag button that keeps its width while it changes to a spinner, Added or Couldn’t add. A product with sizes shows a row of size buttons that slides up over the photo on hover or keyboard focus, with sold-out sizes crossed out. On touch screens a + button opens a bottom sheet with Variant Picker instead. A sold-out product hides quick add, shows a Sold out badge and a Notify me link.

Badges come from the data: Sale −20%, Only 3 left, Pre-order and Sold out, plus your own such as New, two at most. The wishlist heart saves optimistically, pops once when saved, and rolls back with a message if your callback throws. ProductGrid lays cards out in 2, 3 or 4 columns and animates between them, and ProductCardSkeleton matches the card’s shape exactly for loading states.

  1. Obtén HextaUI Pro

    Un solo pago desbloquea todos los bloques, incluidos los futuros. Ver precios.

  2. Añade el registro Pro a components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Añade tu token

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  4. Añade el bloque

    pnpm dlx shadcn@latest add @hextaui-pro/product-card

A Shopify collection

Map each Storefront API product once and pass it in. onQuickAdd gets the chosen variant, so adding to the cart is one fetch. If your route throws, its message shows on the card where the shopper clicked.

import { fromShopify } from "../variant-picker/shopify"
import { ProductCard, ProductGrid } from "@/components/blocks/product-card/product-card"

type ShopifyProduct = Parameters<typeof fromShopify>[0] & {
  rating: { value: string } | null
  ratingCount: { value: string } | null
}

type Collection = {
  title: string
  products: { nodes: ShopifyProduct[] }
}

async function addToCart(merchandiseId: string) {
  const response = await fetch("/api/cart/lines", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ merchandiseId, quantity: 1 }),
  })
  if (!response.ok) {
    const { message } = await response.json()
    throw new Error(message ?? "Couldn’t add this to your bag. Try again.")
  }
}

export function CollectionGrid({ collection }: { collection: Collection }) {
  return (
    <ProductGrid columns={4} aria-label={collection.title}>
      {collection.products.nodes.map((node, index) => {
        const product = fromShopify(node)
        const count = Number(node.ratingCount?.value ?? 0)
        return (
          <ProductCard
            key={product.id}
            product={product}
            href={`/products/${node.handle}`}
            priority={index < 4}
            rating={count > 0 ? { value: Number(JSON.parse(node.rating?.value ?? "{}").value ?? 0), count } : null}
            onQuickAdd={(variant) => addToCart(variant.id)}
          />
        )
      })}
    </ProductGrid>
  )
}

A recommendations rail

Put compact cards in a horizontally scrolling list for “You may also like” or “Complete the look”. Each card keeps its own swatches and quick add, so the rail needs no extra state.

import type { Product, ProductVariant } from "../variant-picker/product"
import { ProductCard } from "@/components/blocks/product-card/product-card"

type RecommendationsProps = {
  title: string
  products: Product[]
  addToBag: (variantId: string) => Promise<void>
}

export function Recommendations({ title, products, addToBag }: RecommendationsProps) {
  if (products.length === 0) return null
  return (
    <section aria-labelledby="recommendations" className="flex flex-col gap-4">
      <h2 id="recommendations" className="text-lg font-medium">
        {title}
      </h2>
      <ul
        role="list"
        className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-2"
      >
        {products.map((product) => (
          <li key={product.id} className="w-44 shrink-0 snap-start sm:w-56">
            <ProductCard
              product={product}
              size="sm"
              headingLevel={3}
              onQuickAdd={(variant: ProductVariant) => addToBag(variant.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  )
}

A wishlist saved to your API

The heart fills right away and rolls back if the request fails. Signed-out shoppers save to localStorage, so saving never needs an account, and signed-in shoppers save to your API.

"use client"

import * as React from "react"

import type { Product } from "../variant-picker/product"
import { ProductCard, ProductGrid } from "@/components/blocks/product-card/product-card"

const storageKey = "wishlist"

function readLocal(): string[] {
  try {
    return JSON.parse(localStorage.getItem(storageKey) ?? "[]")
  } catch {
    return []
  }
}

export function WishlistGrid({
  products,
  signedIn,
}: {
  products: Product[]
  signedIn: boolean
}) {
  const [saved, setSaved] = React.useState<string[]>([])

  React.useEffect(() => {
    if (!signedIn) {
      setSaved(readLocal())
      return
    }
    fetch("/api/wishlist")
      .then((response) => response.json())
      .then((data: { productIds: string[] }) => setSaved(data.productIds))
  }, [signedIn])

  const change = async (next: boolean, product: Product) => {
    const ids = next
      ? [...saved, product.id]
      : saved.filter((id) => id !== product.id)
    if (signedIn) {
      const response = await fetch(`/api/wishlist/${encodeURIComponent(product.id)}`, {
        method: next ? "PUT" : "DELETE",
      })
      if (!response.ok) throw new Error("Couldn’t update your wishlist. Try again.")
    } else {
      localStorage.setItem(storageKey, JSON.stringify(ids))
    }
    setSaved(ids)
  }

  return (
    <ProductGrid columns={3}>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          wishlisted={saved.includes(product.id)}
          onWishlistChange={change}
        />
      ))}
    </ProductGrid>
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
ProductCardPhoto, badges, wishlist heart, quick add, swatches, vendor, title, price and rating.
ProductGridA responsive list of cards with 2, 3 or 4 columns and a loading state.
ProductCardSkeletonA loading placeholder with the card’s exact geometry.
getAutoBadges, getCardPrice, getQuickAddMode, getSizeChipsThe pure helpers behind the card, usable on the server.

ProductCard

Also takes article props.

PropTipoPredeterminado
productThe product with its options, variants and images.
Product–
hrefWhere the card links. A string gets ?color= added once the shopper picks a color.
string | (color) => string"/products/{handle}"
linkRenderRender the title link with your router’s Link, such as <Link prefetch />.
ReactElement<a>–
selectedColorThe chosen swatch value when controlled. The swatch option is the one with swatches.
string–
defaultSelectedColorThe starting swatch value. Defaults to the first color in stock.
string–
onSelectedColorChangeCalled when the shopper picks a color.
(color: string) => void–
badgesYour own badges, shown after the automatic ones.
(string | { id; label; tone? })[]–
autoBadgesShow Sale, Low stock, Pre-order and Sold out from the data.
booleantrue
maxBadgesThe most badges shown at once.
number2
ratingShows stars with “4.8 (126)”. Hidden when count is 0.
{ value: number; count: number } | null–
wishlistedWhether the product is saved, when controlled.
boolean–
defaultWishlistedThe starting saved state when uncontrolled.
booleanfalse
onWishlistChangeShows the heart. The heart changes right away and rolls back if this throws or rejects.
(wishlisted, product) => void | Promise<void>–
onQuickAddShows quick add. The card shows pending, added and error states from the returned promise.
(variant, product) => void | Promise<void>–
notifyHrefWhere Notify me links for a sold-out product. Defaults to the product link with ?notify=1.
string–
sizeCard density. Inside ProductGrid it follows the column count.
"sm" | "default"–
priorityLoad the photo eagerly with high priority, for cards above the fold.
booleanfalse
imageSizesThe sizes attribute for the photo.
string–
headingLevelThe heading level of the title.
2 | 3 | 43
localeLocale for prices and counts.
Intl.LocalesArgument"en-US"
trailingZerosShow $48.00 instead of $48.
booleanfalse
labelsWords for buttons, badges, swatch names and announcements.
Partial<ProductCardLabels>–

ProductGrid

Also takes ul props. Each child is wrapped in a list item.

PropTipoPredeterminado
columnsColumns on wide screens. Phones show 1 column for 2 and 2 columns otherwise.
2 | 3 | 43
sizeCard density. Defaults to sm at 4 columns.
"sm" | "default"–
loadingShow skeletons and set aria-busy.
booleanfalse
skeletonCountHow many skeletons to show while loading.
number8
skeletonA custom skeleton for each loading cell.
ReactNode–

ProductCardSkeleton

Also takes div props.

PropTipoPredeterminado
sizeMatch the card density.
"sm" | "default"–
swatchesInclude the swatch row.
booleantrue
ratingInclude the rating line.
booleanfalse
KeyAcción
TabMoves from the product link to the heart, the quick add buttons and each swatch. Focusing the card reveals the size row.
EnterOpens the product page from the link, or adds the focused size.
SpaceToggles the heart, picks the focused swatch or adds the focused size.
EscapeCloses the quick add sheet and returns focus to the + button.
  • The card is one link named by the product title, inside a heading. The heart, swatches and quick add are separate buttons layered above it, never nested inside the link.
  • Swatches are named like “Sage, 3 of 5” and say when a color is sold out. The chosen one has aria-pressed. Sold-out sizes stay focusable with aria-disabled and are named “Twin, sold out”.
  • The heart uses aria-pressed and the label “Save Ring Vase to wishlist”. A polite live region announces saves, removals and adds, such as “Added Washed Linen Duvet Cover, White / King, to your bag”, and failures are announced as alerts.
  • Hover-only effects are limited to mouse pointers, and the size row also opens on keyboard focus. Motion is transform and opacity only, and reduced motion keeps only short fades.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Product Card. Cada uno se instala por separado.

Código

6 archivos, añadidos a components/blocks/product-card.