-18%
E-commerceecommerceshopcardproductadd-to-cartwishlist
Store Product Card
Shop product tile with illustration or photo, badge, star rating, colour swatches, compare-at price, wishlist toggle and add-to-cart.
Live Preview
Usage
Copy and paste this code into your React component:
tsx
import { StoreProductCard } from 'react-component-kit-ui/ecommerce';
<StoreProductCard href="/product/aurora-headphones/" categoryLabel="Audio" product={{
slug: 'aurora-headphones',
name: 'Aurora Wireless Headphones',
price: 189,
compareAt: 229,
category: 'audio',
rating: 4.8,
reviews: 312,
badge: '-18%',
icon: 'headphones',
hue: 38,
}} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/StoreProductCard.tsx + StoreProductCard.css:
StoreProductCard.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import ProductMedia from './ProductMedia';
import ProductRating from './ProductRating';
import Badge from '../feedback/Badge';
import { formatMoney } from './cart-store';
import type { Product } from './types';
import { bindAddToCart } from './cart-store';
import './StoreProductCard.css';
/** class:list โ className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/** "a: b; --c: d" โ style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
/**
* StoreProductCard โ Shop product tile with media, badge, rating, colour swatches,
* price (with compare-at), wishlist toggle and an add-to-cart button wired to the cart store.
*
* @param {Product} product - Product data (see ecommerce/types.ts). A demo product is used by default.
* @param {string} href - Product page URL. Default '#'.
* @param {string} categoryLabel - Small label above the title (e.g. category name).
* @param {string} currency - ISO currency. Default 'USD'.
* @param {string} locale - Number format locale. Default 'en-US'.
* @param {string} ctaLabel - Add-to-cart label. Default 'Add to cart'.
* @param {string} addedLabel - Label shown briefly after adding. Default 'Added'.
* @param {string} cartKey - Cart storage key (several stores on one domain). Default 'ack-cart'.
* @param {boolean} openCart - Open the CartDrawer after adding; false fires a CartToast instead. Default true.
* @param {string} variant - Visual style (glass|minimal|bordered). Default 'glass'.
* @param {string} size - Card density (sm|md|lg). Default 'md'.
* @param {string} headingLevel - Title element (h2|h3|h4). Default 'h3'.
* @param {string} className - Extra class.
*
* Accessibility: the title link covers the whole card (stretched link) so there are no
* nested interactive elements; wishlist uses aria-pressed; out-of-stock disables the button.
*/
export interface StoreProductCardProps {
product?: Product;
href?: string;
categoryLabel?: string;
currency?: string;
locale?: string;
ctaLabel?: string;
addedLabel?: string;
cartKey?: string;
openCart?: boolean;
variant?: 'glass' | 'minimal' | 'bordered';
size?: 'sm' | 'md' | 'lg';
headingLevel?: 'h2' | 'h3' | 'h4';
className?: string;
}
export function StoreProductCard({
product = {
slug: 'aurora-headphones',
name: 'Aurora Wireless Headphones',
price: 189,
compareAt: 229,
category: 'audio',
rating: 4.8,
reviews: 312,
badge: '-18%',
badgeVariant: 'warning',
colors: [{ name: 'Graphite', hex: '#2b2b2f' }, { name: 'Sand', hex: '#d8c3a5' }, { name: 'Ocean', hex: '#3d6f9e' }],
icon: 'headphones',
hue: 38,
inStock: true,
},
href = '#',
categoryLabel = 'Audio',
currency = 'USD',
locale = 'en-US',
ctaLabel = 'Add to cart',
addedLabel = 'Added',
cartKey = 'ack-cart',
openCart = true,
variant = 'glass',
size = 'md',
headingLevel = 'h3',
className = '',
}: StoreProductCardProps) {
const Heading: any = headingLevel;
const money = { currency, locale };
const inStock = product.inStock !== false;
const cartPayload = JSON.stringify({
id: product.slug,
name: product.name,
price: product.price,
icon: product.icon,
hue: product.hue,
image: product.image,
variant: product.colors?.[0]?.name,
});
const titleId = `spc-${product.slug}`;
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
function init() {
bindAddToCart(document);
document.querySelectorAll<HTMLButtonElement>('[data-wishlist]:not([data-wish-bound])').forEach((btn) => {
btn.dataset.wishBound = '';
btn.addEventListener('click', () => {
btn.setAttribute('aria-pressed', String(btn.getAttribute('aria-pressed') !== 'true'));
});
});
}
init();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<article className={cx(['sp-card', `sp-card--${variant}`, `sp-card--${size}`, 'is-hoverable', className])} aria-labelledby={titleId} data-product data-name={product.name.toLowerCase()} data-category={product.category} data-price={product.price} data-rating={product.rating ?? 0} data-colors={(product.colors ?? []).map((c) => c.name.toLowerCase()).join(',')} data-stock={inStock ? 'in' : 'out'} data-rk-82d407="">
<div className="sp-card__media" data-rk-82d407="">
<ProductMedia image={product.image} alt={product.name} icon={product.icon} hue={product.hue} />
{product.badge && (
<div className="sp-card__badge" data-rk-82d407=""><Badge variant={product.badgeVariant ?? 'default'}>{product.badge}</Badge></div>
)}
<button className="sp-card__wish" type="button" aria-pressed="false" aria-label={`Save ${product.name} to wishlist`} data-wishlist data-rk-82d407="">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-82d407=""><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 21l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8z" data-rk-82d407="" /></svg>
</button>
</div>
<div className="sp-card__body" data-rk-82d407="">
{categoryLabel && <p className="sp-card__cat" data-rk-82d407="">{categoryLabel}</p>}
<Heading className="sp-card__title" id={titleId} data-rk-82d407="">
<a className="sp-card__link" href={href} data-rk-82d407="">{product.name}</a>
</Heading>
{product.rating !== undefined && <ProductRating value={product.rating} count={product.reviews} />}
{product.colors && product.colors.length > 0 && (
<ul className="sp-card__swatches" aria-label={`Colours: ${product.colors.map((c) => c.name).join(', ')}`} data-rk-82d407="">
{product.colors.slice(0, 4).map((c, __i: number) => (
<li key={__i} className="sp-card__swatch" style={st(`--sw: ${c.hex}`)} title={c.name} data-rk-82d407="" />
))}
{product.colors.length > 4 && <li className="sp-card__swatch-more" data-rk-82d407="">+{product.colors.length - 4}</li>}
</ul>
)}
<div className="sp-card__foot" data-rk-82d407="">
<p className="sp-card__price" data-rk-82d407="">
<span className="sp-card__now" data-rk-82d407="">{formatMoney(product.price, money)}</span>
{product.compareAt && (
<s className="sp-card__was" data-rk-82d407=""><span className="sr-only" data-rk-82d407="">Was </span>{formatMoney(product.compareAt, money)}</s>
)}
</p>
<button className="sp-card__add" type="button" data-add-to-cart={cartPayload} data-cart-key={cartKey} data-open-cart={openCart ? undefined : 'false'} data-added-label={addedLabel} disabled={!inStock} aria-label={inStock ? `${ctaLabel}: ${product.name}` : `${product.name} is sold out`} data-rk-82d407="">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-82d407=""><path d="M6 6h15l-1.5 9h-12z" data-rk-82d407="" /><path d="M6 6 5 3H2" data-rk-82d407="" /><circle cx="9" cy="20" r="1.4" data-rk-82d407="" /><circle cx="18" cy="20" r="1.4" data-rk-82d407="" /></svg>
<span data-label data-rk-82d407="">{inStock ? ctaLabel : 'Sold out'}</span>
</button>
</div>
</div>
</article>
</>
);
}
export default StoreProductCard;StoreProductCard.css
/* StoreProductCard โ ported from astro-component-kit (ecommerce/StoreProductCard.astro) */
.sp-card:where([data-rk-82d407]) {
container-type: inline-size;
position: relative;
display: flex;
flex-direction: column;
border-radius: var(--r-lg, 20px);
overflow: hidden;
background: var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
transition: border-color 0.25s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease;
}
.sp-card--glass:where([data-rk-82d407]) {
background: linear-gradient(180deg, rgb(var(--ink-rgb, 255 255 255) / 0.035), rgb(var(--ink-rgb, 255 255 255) / 0.01));
backdrop-filter: blur(10px);
}
.sp-card--minimal:where([data-rk-82d407]) { background: transparent; border-color: transparent; }
.sp-card--bordered:where([data-rk-82d407]) { border-color: rgb(var(--ink-rgb, 255 255 255) / 0.12); }
.sp-card:hover:where([data-rk-82d407]) {
border-color: rgba(212, 160, 23, 0.35);
transform: translateY(-3px);
box-shadow: 0 18px 40px -22px rgba(212, 160, 23, 0.45);
}
.sp-card:focus-within:where([data-rk-82d407]) {
outline: 2px solid var(--c-primary-light, #d4a017);
outline-offset: 2px;
}
.sp-card__media:where([data-rk-82d407]) { position: relative; border-radius: var(--r-lg, 20px) var(--r-lg, 20px) 0 0; }
.sp-card--minimal:where([data-rk-82d407]) .sp-card__media:where([data-rk-82d407]) { border-radius: var(--r-lg, 20px); }
.sp-card__badge:where([data-rk-82d407]) { position: absolute; top: var(--sp-3, 0.75rem); left: var(--sp-3, 0.75rem); z-index: 3; }
.sp-card__wish:where([data-rk-82d407]) {
position: absolute;
top: var(--sp-2, 0.5rem);
right: var(--sp-2, 0.5rem);
z-index: 3;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: var(--r-full, 9999px);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
background: rgb(var(--paper-rgb, 5 5 5) / 0.55);
backdrop-filter: blur(8px);
color: var(--c-text-1, #fff);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.sp-card__wish:hover:where([data-rk-82d407]) { transform: scale(1.06); }
.sp-card__wish[aria-pressed='true']:where([data-rk-82d407]) { color: var(--c-danger, #f87171); }
.sp-card__wish[aria-pressed='true']:where([data-rk-82d407]) svg:where([data-rk-82d407]) { fill: currentColor; }
.sp-card__wish:focus-visible:where([data-rk-82d407]),
.sp-card__add:focus-visible:where([data-rk-82d407]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.sp-card__body:where([data-rk-82d407]) {
display: flex;
flex-direction: column;
gap: var(--sp-2, 0.5rem);
padding: var(--sp-4, 1rem) var(--sp-4, 1rem) var(--sp-5, 1.25rem);
flex: 1;
}
.sp-card--sm:where([data-rk-82d407]) .sp-card__body:where([data-rk-82d407]) { padding: var(--sp-3, 0.75rem); }
.sp-card--lg:where([data-rk-82d407]) .sp-card__body:where([data-rk-82d407]) { padding: var(--sp-6, 1.5rem); }
.sp-card--minimal:where([data-rk-82d407]) .sp-card__body:where([data-rk-82d407]) { padding-inline: 0; }
.sp-card__cat:where([data-rk-82d407]) {
margin: 0;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-primary-light, #d4a017);
}
.sp-card__title:where([data-rk-82d407]) { margin: 0; font-size: 1rem; font-weight: 700; line-height: 1.3; }
.sp-card--lg:where([data-rk-82d407]) .sp-card__title:where([data-rk-82d407]) { font-size: 1.15rem; }
.sp-card__link:where([data-rk-82d407]) { color: var(--c-text-1, #fff); text-decoration: none; }
.sp-card__link:focus-visible:where([data-rk-82d407]) { outline: none; }
.sp-card__link:where([data-rk-82d407])::after { content: ''; position: absolute; inset: 0; z-index: 2; }
.sp-card__swatches:where([data-rk-82d407]) { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; padding: 0; list-style: none; }
.sp-card__swatch:where([data-rk-82d407]) {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--sw);
box-shadow: 0 0 0 1px rgb(var(--ink-rgb, 255 255 255) / 0.25);
}
.sp-card__swatch-more:where([data-rk-82d407]) { font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }
.sp-card__foot:where([data-rk-82d407]) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3, 0.75rem);
margin-top: auto;
padding-top: var(--sp-2, 0.5rem);
}
.sp-card__price:where([data-rk-82d407]) { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 var(--sp-2, 0.5rem); margin: 0; }
.sp-card__now:where([data-rk-82d407]) { font-size: 1.1rem; font-weight: 800; color: var(--c-text-1, #fff); }
.sp-card__was:where([data-rk-82d407]) { font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); }
.sp-card__add:where([data-rk-82d407]) {
position: relative;
z-index: 3;
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
padding: 0 var(--sp-4, 1rem);
border: 0;
border-radius: var(--r-full, 9999px);
background: var(--c-primary-light, #d4a017);
color: var(--c-on-primary, #050505);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: transform 0.2s ease, filter 0.2s ease, background 0.2s ease;
}
.sp-card__add:hover:where([data-rk-82d407]) { filter: brightness(1.08); transform: translateY(-1px); }
.sp-card__add:disabled:where([data-rk-82d407]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.08); color: var(--c-text-2, #a3a3a3); cursor: not-allowed; transform: none; }
.sp-card__add.is-added:where([data-rk-82d407]) { background: var(--c-success, #34d399); }
.sp-card--sm:where([data-rk-82d407]) .sp-card__add:where([data-rk-82d407]) span:where([data-rk-82d407]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.sp-card--sm:where([data-rk-82d407]) .sp-card__add:where([data-rk-82d407]) { width: 44px; padding: 0; justify-content: center; }
.sr-only:where([data-rk-82d407]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* Narrow cards (2-up on phones): stack price above a full-width button */
@container (max-width: 250px) {
.sp-card__body:where([data-rk-82d407]) { padding: var(--sp-3, 0.75rem) var(--sp-3, 0.75rem) var(--sp-4, 1rem); }
.sp-card__foot:where([data-rk-82d407]) { flex-direction: column; align-items: stretch; gap: var(--sp-2, 0.5rem); }
.sp-card__add:where([data-rk-82d407]) { justify-content: center; }
.sp-card__title:where([data-rk-82d407]) { font-size: 0.92rem; }
}
@media (prefers-reduced-motion: reduce) {
.sp-card:where([data-rk-82d407]), .sp-card__add:where([data-rk-82d407]), .sp-card__wish:where([data-rk-82d407]) { transition: none; }
.sp-card:hover:where([data-rk-82d407]) { transform: none; }
}Quick Info
- Category
- E-commerce
- Filename
StoreProductCard.tsx- Dependencies
- None โ pure React + CSS
- Tags
- ecommerceshopcardproductadd-to-cartwishlist