1 GitHub star = 1 new component.

Star the repo
E-commerceecommerceshopcardproducteditorialquick-add

Product Card Minimal

Editorial product card: tall media that swaps view on hover, quick-add sizes, name, price and colour count.

Live Preview

Usage

Copy and paste this code into your React component:

tsx
import { ProductCardMinimal } from 'react-component-kit-ui/ecommerce';

<ProductCardMinimal href="/product/linen-overshirt/" index={1} product={{
  slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing',
  sizes: ['S', 'M', 'L'], icon: 'shirt', hue: 30,
}} />

Manual Installation

If you are not using the npm package, create a new file and paste the following code: src/components/ProductCardMinimal.tsx + ProductCardMinimal.css:

ProductCardMinimal.tsx
'use client';

import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import ProductMedia from './ProductMedia';
import { formatMoney } from './cart-store';
import type { Product } from './types';
import { bindAddToCart } from './cart-store';
import './ProductCardMinimal.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;
}

/**
 * ProductCardMinimal โ€” Editorial product card: tall media that swaps to a second view on hover,
 * quick-add size buttons, name, price and colour count. Square corners, typography-led.
 *
 * @param {Product} product - Product data. A demo product is used by default.
 * @param {string} href - Product page URL. Default '#'.
 * @param {number} index - Optional catalogue number shown as "Nยฐ 01".
 * @param {string} currency - ISO currency. Default 'EUR'.
 * @param {string} locale - Number format locale. Default 'en-GB'.
 * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
 * @param {boolean} quickAdd - Show quick-add (sizes, or a single "Add" button). Default true.
 * @param {string} ratio - Media aspect ratio. Default '3 / 4'.
 * @param {string} variant - Style (plain|framed). Default 'plain'.
 * @param {string} size - Type scale (sm|md|lg). Default 'md'.
 * @param {string} headingLevel - Title element (h2|h3|h4). Default 'h3'.
 * @param {string} className - Extra class.
 *
 * Accessibility: the title is a stretched link; quick-add buttons sit above it with their own
 * labels ("Add size M"), are reachable by keyboard and appear on focus as well as hover.
 */
export interface ProductCardMinimalProps {
  product?: Product;
  href?: string;
  index?: number;
  currency?: string;
  locale?: string;
  cartKey?: string;
  quickAdd?: boolean;
  ratio?: string;
  variant?: 'plain' | 'framed';
  size?: 'sm' | 'md' | 'lg';
  headingLevel?: 'h2' | 'h3' | 'h4';
  className?: string;
}

export function ProductCardMinimal({
  product = {
    slug: 'linen-overshirt',
    name: 'Linen Overshirt',
    price: 145,
    category: 'clothing',
    badge: 'New',
    colors: [{ name: 'Oat', hex: '#d9cbb3' }, { name: 'Ink', hex: '#22252b' }, { name: 'Rust', hex: '#9c3d1c' }],
    sizes: ['XS', 'S', 'M', 'L', 'XL'],
    icon: 'shirt',
    hue: 30,
  },
  href = '#',
  index,
  currency = 'EUR',
  locale = 'en-GB',
  cartKey = 'ack-cart',
  quickAdd = true,
  ratio = '3 / 4',
  variant = 'plain',
  size = 'md',
  headingLevel = 'h3',
  className = '',
}: ProductCardMinimalProps) {
  const Heading: any = headingLevel;
  const money = { currency, locale };
  const inStock = product.inStock !== false;
  const base = { id: product.slug, name: product.name, price: product.price, icon: product.icon, hue: product.hue, image: product.image };
  const color = product.colors?.[0]?.name;
  const titleId = `pcm-${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;
    bindAddToCart(document);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <article className={cx(['pcm', `pcm--${variant}`, `pcm--${size}`, 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-5864b0="">
        <div className="pcm__media" data-rk-5864b0="">
          <div className="pcm__view pcm__view--a" data-rk-5864b0="">
            <ProductMedia image={product.image} alt={product.name} icon={product.icon} hue={product.hue} ratio={ratio} variant="soft" size="lg" />
          </div>
          <div className="pcm__view pcm__view--b" aria-hidden="true" data-rk-5864b0="">
            <ProductMedia icon={product.icon} hue={(product.hue ?? 30) + 20} ratio={ratio} variant="glow" size="sm" />
          </div>
          {(product.badge || !inStock) && <span className="pcm__badge" data-rk-5864b0="">{inStock ? product.badge : 'Sold out'}</span>}
          {index !== undefined && <span className="pcm__index" aria-hidden="true" data-rk-5864b0="">Nยฐ {String(index).padStart(2, '0')}</span>}
      
          {quickAdd && inStock && (
            <div className="pcm__quick" role="group" aria-label={`Quick add ${product.name}`} data-rk-5864b0="">
              {product.sizes?.length ? (
                <>
                  <span className="pcm__quick-label" aria-hidden="true" data-rk-5864b0="">Quick add</span>
                  <div className="pcm__sizes" data-rk-5864b0="">
                    {product.sizes.map((s) => (
                      <button type="button" className="pcm__size" data-add-to-cart={JSON.stringify({ ...base, variant: [color, s].filter(Boolean).join(' / ') })} data-cart-key={cartKey} data-open-cart="false" aria-label={`Add ${product.name}, size ${s}`} data-rk-5864b0=""><span data-label data-rk-5864b0="">{s}</span></button>
                    ))}
                  </div>
                </>
              ) : (
                <button type="button" className="pcm__size pcm__size--wide" data-add-to-cart={JSON.stringify({ ...base, variant: color })} data-cart-key={cartKey} data-open-cart="false" data-added-label="Added" aria-label={`Add ${product.name} to bag`} data-rk-5864b0=""><span data-label data-rk-5864b0="">Add to bag +</span></button>
              )}
            </div>
          )}
        </div>
      
        <div className="pcm__info" data-rk-5864b0="">
          <Heading className="pcm__title" id={titleId} data-rk-5864b0=""><a className="pcm__link" href={href} data-rk-5864b0="">{product.name}</a></Heading>
          <p className="pcm__price" data-rk-5864b0="">
            <span data-rk-5864b0="">{formatMoney(product.price, money)}</span>
            {product.compareAt && <s data-rk-5864b0=""><span className="sr-only" data-rk-5864b0="">Was </span>{formatMoney(product.compareAt, money)}</s>}
          </p>
          {product.colors && product.colors.length > 0 && (
            <p className="pcm__colors" data-rk-5864b0="">
              <span className="pcm__dots" aria-hidden="true" data-rk-5864b0="">{product.colors.slice(0, 4).map((c, __i: number) => <i key={__i} style={st(`--sw: ${c.hex}`)} data-rk-5864b0=""></i>)}</span>
              {product.colors.length} {product.colors.length === 1 ? 'colour' : 'colours'}
            </p>
          )}
        </div>
      </article>
    </>
  );
}

export default ProductCardMinimal;
ProductCardMinimal.css
/* ProductCardMinimal โ€” ported from astro-component-kit (ecommerce/ProductCardMinimal.astro) */

.pcm:where([data-rk-5864b0]) { position: relative; display: flex; flex-direction: column; gap: var(--sp-3, 0.75rem); min-width: 0; }
.pcm--framed:where([data-rk-5864b0]) { padding: var(--sp-3, 0.75rem); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.pcm__media:where([data-rk-5864b0]) { position: relative; overflow: hidden; border-radius: var(--r-sm, 2px); background: var(--c-bg-2, #111); }
.pcm__view:where([data-rk-5864b0]) { transition: opacity 0.5s ease, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1); }
.pcm__view--b:where([data-rk-5864b0]) { position: absolute; inset: 0; opacity: 0; transform: scale(1.04); }
.pcm__view--b:where([data-rk-5864b0]) .p-media__art { transform: rotate(-12deg) scale(1.25); }
.pcm:hover:where([data-rk-5864b0]) .pcm__view--b:where([data-rk-5864b0]), .pcm:focus-within:where([data-rk-5864b0]) .pcm__view--b:where([data-rk-5864b0]) { opacity: 1; transform: none; }
.pcm:hover:where([data-rk-5864b0]) .pcm__view--a:where([data-rk-5864b0]) { transform: scale(1.03); }

.pcm__badge:where([data-rk-5864b0]), .pcm__index:where([data-rk-5864b0]) {
  position: absolute;
  top: var(--sp-3, 0.75rem);
  z-index: 2;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.pcm__badge:where([data-rk-5864b0]) { left: var(--sp-3, 0.75rem); padding: 0.3rem 0.55rem; background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); }
.pcm__index:where([data-rk-5864b0]) { right: var(--sp-3, 0.75rem); color: var(--c-text-2, #a3a3a3); font-variant-numeric: tabular-nums; }

.pcm__quick:where([data-rk-5864b0]) {
  position: absolute;
  left: var(--sp-2, 0.5rem);
  right: var(--sp-2, 0.5rem);
  bottom: var(--sp-2, 0.5rem);
  z-index: 3;
  display: grid;
  gap: 6px;
  padding: var(--sp-3, 0.75rem);
  background: rgb(var(--paper-rgb, 5 5 5) / 0.92);
  backdrop-filter: blur(10px);
  transform: translateY(calc(100% + 12px));
  transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.pcm:hover:where([data-rk-5864b0]) .pcm__quick:where([data-rk-5864b0]), .pcm__quick:focus-within:where([data-rk-5864b0]) { transform: none; }
.pcm__quick-label:where([data-rk-5864b0]) { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.pcm__sizes:where([data-rk-5864b0]) { display: flex; flex-wrap: wrap; gap: 4px; }
.pcm__size:where([data-rk-5864b0]) {
  flex: 1 0 36px;
  min-height: 36px;
  padding: 0 6px;
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.18);
  border-radius: var(--r-sm, 2px);
  background: transparent;
  color: var(--c-text-1, #fff);
  font: inherit;
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.pcm__size:hover:where([data-rk-5864b0]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); border-color: var(--c-text-1, #fff); }
.pcm__size.is-added:where([data-rk-5864b0]) { background: var(--shop-accent, var(--c-primary-light, #d4a017)); border-color: transparent; color: var(--c-on-primary, #050505); }
.pcm__size--wide:where([data-rk-5864b0]) { flex: 1 1 100%; letter-spacing: 0.04em; }
.pcm__size:focus-visible:where([data-rk-5864b0]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }

.pcm__info:where([data-rk-5864b0]) { display: grid; gap: 2px; }
.pcm__title:where([data-rk-5864b0]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: 1.05rem; font-weight: 500; letter-spacing: -0.01em; line-height: 1.25; }
.pcm--sm:where([data-rk-5864b0]) .pcm__title:where([data-rk-5864b0]) { font-size: 0.95rem; }
.pcm--lg:where([data-rk-5864b0]) .pcm__title:where([data-rk-5864b0]) { font-size: 1.3rem; }
.pcm__link:where([data-rk-5864b0]) { color: var(--c-text-1, #fff); text-decoration: none; }
.pcm__link:where([data-rk-5864b0])::after { content: ''; position: absolute; inset: 0; z-index: 1; }
.pcm__link:focus-visible:where([data-rk-5864b0]) { outline: none; }
.pcm:focus-within:not(:has(.pcm__quick:focus-within)):where([data-rk-5864b0]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 4px; }
.pcm__price:where([data-rk-5864b0]) { display: flex; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.9rem; color: var(--c-text-1, #fff); font-variant-numeric: tabular-nums; }
.pcm__price:where([data-rk-5864b0]) s:where([data-rk-5864b0]) { color: var(--c-text-2, #a3a3a3); }
.pcm__colors:where([data-rk-5864b0]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 2px 0 0; font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.pcm__dots:where([data-rk-5864b0]) { display: inline-flex; }
.pcm__dots:where([data-rk-5864b0]) i:where([data-rk-5864b0]) { width: 10px; height: 10px; margin-right: -3px; border-radius: 50%; background: var(--sw); box-shadow: 0 0 0 1.5px var(--c-bg, #050505); }

.sr-only:where([data-rk-5864b0]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@media (hover: none) {
  .pcm__quick:where([data-rk-5864b0]) { position: static; transform: none; padding: var(--sp-2, 0.5rem) 0 0; background: transparent; backdrop-filter: none; }
  .pcm__quick-label:where([data-rk-5864b0]) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .pcm__view:where([data-rk-5864b0]), .pcm__quick:where([data-rk-5864b0]) { transition: none; }
}

Quick Info

Category
E-commerce
Filename
ProductCardMinimal.tsx
Dependencies
None โ€” pure React + CSS
Tags
ecommerceshopcardproducteditorialquick-add