1 GitHub star = 1 new component.

Star the repo
E-commerceecommerceshopproduct-pageadd-to-cartpdp

Product Buy Box

Product detail purchase panel: price, savings, colour/size pickers, quantity stepper, add to cart, buy now and expandable details.

Live Preview

Audio

Bestseller

Aurora Wireless Headphones

$189.00Was $229.00Save 17%

Adaptive noise cancelling, 40-hour battery and memory-foam cushions that disappear after five minutes. Tuned in-house for a warm, detailed sound.

Colour: Graphite
Buy now

In stock โ€” ships in 24 hours

  • Free shippingOver $75
  • 30-day returnsFree & easy
Features

Hybrid ANC with transparency mode ยท Bluetooth 5.4 multipoint ยท USB-C fast charge (10 min = 5 h) ยท Foldable aluminium frame.

Shipping & returns

Free standard shipping over $75, delivered in 2โ€“4 business days. Return anything within 30 days for a full refund.

Warranty

Two-year limited warranty covering manufacturing defects, with free repair or replacement.

Usage

Copy and paste this code into your React component:

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

<ProductBuyBox product={product} checkoutHref="/checkout/" />

Manual Installation

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

ProductBuyBox.tsx
'use client';

import { useEffect, useRef } from 'react';
import Badge from '../feedback/Badge';
import StepperInput from '../forms/StepperInput';
import ProductRating from './ProductRating';
import VariantPicker from './VariantPicker';
import TrustBadges from './TrustBadges';
import StockIndicator from './StockIndicator';
import { formatMoney } from './cart-store';
import type { Product } from './types';
import { bindAddToCart } from './cart-store';
import './ProductBuyBox.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(' ');
}

/**
 * ProductBuyBox โ€” Product detail purchase panel: title, rating, price, colour/size pickers,
 * quantity (StepperInput), add to cart, buy now, guarantees and expandable details.
 *
 * @param {Product} product - Product data. A demo product is used by default.
 * @param {string} categoryLabel - Eyebrow text (e.g. the category name).
 * @param {string} checkoutHref - Where "Buy now" goes after adding the item. Default '#'.
 * @param {Array<{title: string, body: string}>} details - Expandable sections (materials, shippingโ€ฆ).
 * @param {string} currency - ISO currency. Default 'USD'.
 * @param {string} locale - Number format locale. Default 'en-US'.
 * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
 * @param {string} id - Base id for inner controls. Default 'buy-box'.
 * @param {string} variant - Style (plain|glass). Default 'plain'.
 * @param {string} size - Type scale (md|lg). Default 'md'.
 * @param {string} className - Extra class.
 *
 * Accessibility: one h1 with the product name; pickers are radio groups; details use native
 * <details>/<summary>; sold-out state disables the buttons and says so in text.
 */
export interface ProductBuyBoxProps {
  product?: Product;
  categoryLabel?: string;
  checkoutHref?: string;
  details?: Array<{ title: string; body: string }>;
  currency?: string;
  locale?: string;
  cartKey?: string;
  id?: string;
  variant?: 'plain' | 'glass';
  size?: 'md' | 'lg';
  className?: string;
}

export function ProductBuyBox({
  product = {
    slug: 'aurora-headphones',
    name: 'Aurora Wireless Headphones',
    price: 189,
    compareAt: 229,
    category: 'audio',
    rating: 4.8,
    reviews: 312,
    badge: 'Bestseller',
    badgeVariant: 'warning',
    colors: [{ name: 'Graphite', hex: '#2b2b2f' }, { name: 'Sand', hex: '#d8c3a5' }, { name: 'Ocean', hex: '#3d6f9e' }],
    icon: 'headphones',
    hue: 38,
    description: 'Adaptive noise cancelling, 40-hour battery and memory-foam cushions that disappear after five minutes. Tuned in-house for a warm, detailed sound.',
    inStock: true,
  },
  categoryLabel = 'Audio',
  checkoutHref = '#',
  details = [
    { title: 'Features', body: 'Hybrid ANC with transparency mode ยท Bluetooth 5.4 multipoint ยท USB-C fast charge (10 min = 5 h) ยท Foldable aluminium frame.' },
    { title: 'Shipping & returns', body: 'Free standard shipping over $75, delivered in 2โ€“4 business days. Return anything within 30 days for a full refund.' },
    { title: 'Warranty', body: 'Two-year limited warranty covering manufacturing defects, with free repair or replacement.' },
  ],
  currency = 'USD',
  locale = 'en-US',
  cartKey = 'ack-cart',
  id = 'buy-box',
  variant = 'plain',
  size = 'md',
  className = '',
}: ProductBuyBoxProps) {
  const money = { currency, locale };
  const inStock = product.inStock !== false;
  const saving = product.compareAt ? Math.round((1 - product.price / product.compareAt) * 100) : 0;
  const payload = JSON.stringify({ id: product.slug, name: product.name, price: product.price, icon: product.icon, hue: product.hue, image: product.image });
  const addId = `${id}-add`;

  // 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);
      document.querySelectorAll<HTMLAnchorElement>('[data-buy-now]').forEach((link) => {
        link.addEventListener('click', (e) => {
          const btn = document.getElementById(link.dataset.buyNow || '') as HTMLButtonElement | null;
          if (!btn || btn.disabled) return;
          e.preventDefault();
          btn.dataset.openCart = 'false';
          btn.click();
          window.location.href = link.href;
        });
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className={cx(['buy-box', `buy-box--${variant}`, `buy-box--${size}`, className])} data-rk-ed869a="">
        <div className="buy-box__top" data-rk-ed869a="">
          {categoryLabel && <p className="buy-box__cat" data-rk-ed869a="">{categoryLabel}</p>}
          {product.badge && <Badge variant={product.badgeVariant ?? 'default'}>{product.badge}</Badge>}
        </div>
        <h1 className="buy-box__title" data-rk-ed869a="">{product.name}</h1>
        {product.rating !== undefined && (
          <a className="buy-box__rating" href="#reviews" data-rk-ed869a="">
            <ProductRating value={product.rating} count={product.reviews} size="md" />
          </a>
        )}
      
        <p className="buy-box__price" data-rk-ed869a="">
          <span className="buy-box__now" data-rk-ed869a="">{formatMoney(product.price, money)}</span>
          {product.compareAt && <s className="buy-box__was" data-rk-ed869a=""><span className="sr-only" data-rk-ed869a="">Was </span>{formatMoney(product.compareAt, money)}</s>}
          {saving > 0 && <span className="buy-box__save" data-rk-ed869a="">Save {saving}%</span>}
        </p>
        {product.description && <p className="buy-box__desc" data-rk-ed869a="">{product.description}</p>}
      
        <div className="buy-box__pickers" data-rk-ed869a="">
          {product.colors && product.colors.length > 0 && (
            <VariantPicker label="Colour" name={`${id}-color`} type="swatch" target={addId} options={product.colors.map((c) => ({ label: c.name, hex: c.hex }))} />
          )}
          {product.sizes && product.sizes.length > 0 && (
            <VariantPicker label="Size" name={`${id}-size`} type="pill" target={addId} options={product.sizes.map((s) => ({ label: s }))} />
          )}
        </div>
      
        <div className="buy-box__actions" data-rk-ed869a="">
          <StepperInput id={`${id}-qty`} name="qty" value={1} min={1} max={10} />
          <button className="buy-box__add" id={addId} type="button" data-add-to-cart={payload} data-cart-key={cartKey} data-qty-from={`${id}-qty`} data-added-label="Added to cart" disabled={!inStock} data-rk-ed869a="">
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-ed869a=""><path d="M6 7h12l-1 13H7z" data-rk-ed869a="" /><path d="M9 7V6a3 3 0 0 1 6 0v1" data-rk-ed869a="" /></svg>
            <span data-label data-rk-ed869a="">{inStock ? 'Add to cart' : 'Sold out'}</span>
          </button>
        </div>
        {inStock && (
          <a className="buy-box__buy" href={checkoutHref} data-buy-now={addId} data-rk-ed869a="">Buy now</a>
        )}
      
        {inStock && product.stock !== undefined && product.stock <= 5 ? (
          <StockIndicator stock={product.stock} viewers={8 + (product.reviews ?? 0) % 17} />
        ) : (
          <p className="buy-box__stock" data-in={inStock ? '' : undefined} data-rk-ed869a="">
            <span aria-hidden="true" data-rk-ed869a=""></span>{inStock ? 'In stock โ€” ships in 24 hours' : 'Out of stock โ€” back soon'}
          </p>
        )}
      
        <TrustBadges variant="inline" size="sm" items={[
          { icon: 'truck', title: 'Free shipping', text: 'Over $75' },
          { icon: 'return', title: '30-day returns', text: 'Free & easy' },
        ]} />
      
        {details.length > 0 && (
          <div className="buy-box__details" data-rk-ed869a="">
            {details.map((d, i) => (
              <details key={i} open={i === 0} data-rk-ed869a="">
                <summary data-rk-ed869a="">{d.title}</summary>
                <p data-rk-ed869a="">{d.body}</p>
              </details>
            ))}
          </div>
        )}
      </div>
    </>
  );
}

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

.buy-box:where([data-rk-ed869a]) { display: flex; flex-direction: column; gap: var(--sp-4, 1rem); min-width: 0; }
.buy-box--glass:where([data-rk-ed869a]) {
  padding: var(--sp-6, 1.5rem);
  border-radius: var(--r-xl, 28px);
  border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
  background: var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
}
.buy-box__top:where([data-rk-ed869a]) { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); }
.buy-box__cat:where([data-rk-ed869a]) { margin: 0; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--c-primary-light, #d4a017); }
.buy-box__title:where([data-rk-ed869a]) { margin: 0; font-size: clamp(1.8rem, 3.6vw, 2.6rem); font-weight: 900; line-height: 1.08; letter-spacing: -0.035em; color: var(--c-text-1, #fff); }
.buy-box--lg:where([data-rk-ed869a]) .buy-box__title:where([data-rk-ed869a]) { font-size: clamp(2.2rem, 4.4vw, 3.2rem); }
.buy-box__rating:where([data-rk-ed869a]) { width: fit-content; text-decoration: none; border-radius: var(--r-sm, 8px); }
.buy-box__rating:hover:where([data-rk-ed869a]) .p-rating__count { text-decoration: underline; }

.buy-box__price:where([data-rk-ed869a]) { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--sp-3, 0.75rem); margin: 0; }
.buy-box__now:where([data-rk-ed869a]) { font-size: 1.9rem; font-weight: 900; color: var(--c-text-1, #fff); letter-spacing: -0.02em; }
.buy-box__was:where([data-rk-ed869a]) { font-size: 1.05rem; color: var(--c-text-2, #a3a3a3); }
.buy-box__save:where([data-rk-ed869a]) { padding: 0.2rem 0.6rem; border-radius: var(--r-full, 9999px); background: rgba(52, 211, 153, 0.12); color: var(--c-success, #34d399); font-size: 0.78rem; font-weight: 800; }
.buy-box__desc:where([data-rk-ed869a]) { margin: 0; line-height: 1.7; color: var(--c-text-2, #a3a3a3); }

.buy-box__pickers:where([data-rk-ed869a]) { display: grid; gap: var(--sp-5, 1.25rem); padding: var(--sp-2, 0.5rem) 0; }

.buy-box__actions:where([data-rk-ed869a]) { display: flex; gap: var(--sp-3, 0.75rem); align-items: stretch; }
.buy-box__actions:where([data-rk-ed869a]) .stepper-container { width: auto; flex-shrink: 0; }
.buy-box__actions:where([data-rk-ed869a]) .stepper { height: 52px; border-radius: var(--r-full, 9999px); border-color: rgb(var(--ink-rgb, 255 255 255) / 0.12); }
.buy-box__actions:where([data-rk-ed869a]) .stepper-btn { width: 48px; height: 100%; background: transparent; color: var(--c-text-1, #fff); }
.buy-box__actions:where([data-rk-ed869a]) .stepper-btn:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.07); }
.buy-box__actions:where([data-rk-ed869a]) .stepper-input { width: 44px; height: 100%; }
.buy-box__add:where([data-rk-ed869a]),
.buy-box__buy:where([data-rk-ed869a]) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2, 0.5rem);
  min-height: 52px;
  border-radius: var(--r-full, 9999px);
  font: inherit;
  font-weight: 800;
  font-size: 0.95rem;
  text-decoration: none;
  cursor: pointer;
  transition: filter 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.buy-box__add:where([data-rk-ed869a]) { flex: 1; border: 0; background: var(--c-primary-light, #d4a017); color: var(--c-on-primary, #050505); }
.buy-box__add:hover:where([data-rk-ed869a]) { filter: brightness(1.08); transform: translateY(-1px); }
.buy-box__add.is-added:where([data-rk-ed869a]) { background: var(--c-success, #34d399); }
.buy-box__add:disabled:where([data-rk-ed869a]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.08); color: var(--c-text-2, #a3a3a3); cursor: not-allowed; transform: none; }
.buy-box__buy:where([data-rk-ed869a]) { border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.16); color: var(--c-text-1, #fff); }
.buy-box__buy:hover:where([data-rk-ed869a]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
.buy-box:where([data-rk-ed869a]) :is(a, button, summary):focus-visible:where([data-rk-ed869a]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }

.buy-box__stock:where([data-rk-ed869a]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.85rem; color: var(--c-danger, #f87171); }
.buy-box__stock[data-in]:where([data-rk-ed869a]) { color: var(--c-success, #34d399); }
.buy-box__stock:where([data-rk-ed869a]) span:where([data-rk-ed869a]) { width: 8px; height: 8px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; }

.buy-box__details:where([data-rk-ed869a]) { border-top: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); }
.buy-box__details:where([data-rk-ed869a]) details:where([data-rk-ed869a]) { border-bottom: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); }
.buy-box__details:where([data-rk-ed869a]) summary:where([data-rk-ed869a]) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 52px;
  font-weight: 700;
  color: var(--c-text-1, #fff);
  cursor: pointer;
  list-style: none;
}
.buy-box__details:where([data-rk-ed869a]) summary:where([data-rk-ed869a])::-webkit-details-marker { display: none; }
.buy-box__details:where([data-rk-ed869a]) summary:where([data-rk-ed869a])::after { content: '+'; font-size: 1.3rem; font-weight: 400; color: var(--c-text-2, #a3a3a3); transition: transform 0.2s ease; }
.buy-box__details:where([data-rk-ed869a]) details[open]:where([data-rk-ed869a]) summary:where([data-rk-ed869a])::after { transform: rotate(45deg); }
.buy-box__details:where([data-rk-ed869a]) p:where([data-rk-ed869a]) { margin: 0 0 var(--sp-4, 1rem); font-size: 0.9rem; line-height: 1.7; color: var(--c-text-2, #a3a3a3); }

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

Quick Info

Category
E-commerce
Filename
ProductBuyBox.tsx
Dependencies
None โ€” pure React + CSS
Tags
ecommerceshopproduct-pageadd-to-cartpdp