1 GitHub star = 1 new component.

Star the repo
E-commerce

Shop Lookbook

"Shop the look" image with numbered hotspots that open product cards with add-to-bag, mirrored by an accessible product list.

lookbookhotspotsecommerceshoppable
Live Preview

Lookbook

Shop the look

A Sunday uniform: a soft overshirt, a tote that goes everywhere and the mug that starts the day.

  1. Linen Overshirtโ‚ฌ145.00
  2. Field Toteโ‚ฌ120.00
  3. Kiln Mugโ‚ฌ32.00

The complete lookโ‚ฌ297.00

Usage

Copy and paste this code into your React component:

tsx
import { ShopLookbook } from 'react-component-kit-ui/sections';

<ShopLookbook title="Shop the *look*" spots={[
  { x: 40, y: 32, product: { slug: 'overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', icon: 'shirt' }, href: '/product/overshirt/' },
]} />

Manual Installation

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

ShopLookbook.tsx
'use client';

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

interface Spot { x: number; y: number; product: Product; href?: string }

/**
 * ShopLookbook โ€” "Shop the look": a large editorial image with numbered hotspots. Each hotspot
 * opens a small product card (name, price, link and add to bag) and highlights it in the side list.
 *
 * @param {string} eyebrow - Small label. Default 'Lookbook'.
 * @param {string} title - Heading (use *asterisks* for italics). Default 'Shop the *look*'.
 * @param {string} text - Intro copy.
 * @param {{icon?: string, hue?: number, image?: string, alt?: string}} media - Scene visual.
 * @param {Array<{x: number, y: number, product: Product, href?: string}>} spots - Hotspots; x/y in % of the image.
 * @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 {string} variant - Layout (image-left|image-right). Default 'image-left'.
 * @param {string} id - Base id. Default 'lookbook'.
 * @param {string} className - Extra class.
 *
 * Accessibility: hotspots are buttons with full labels and aria-expanded; the same products are
 * listed as plain links next to the image, so the look is usable without the hotspots.
 */
export interface ShopLookbookProps {
  eyebrow?: string;
  title?: string;
  text?: string;
  media?: { icon?: string; hue?: number; image?: string; alt?: string };
  spots?: Spot[];
  currency?: string;
  locale?: string;
  cartKey?: string;
  variant?: 'image-left' | 'image-right';
  id?: string;
  className?: string;
}

export function ShopLookbook({
  eyebrow = 'Lookbook',
  title = 'Shop the *look*',
  text = 'A Sunday uniform: a soft overshirt, a tote that goes everywhere and the mug that starts the day.',
  media = { icon: 'jacket', hue: 22 },
  spots = [
    { x: 38, y: 34, product: { slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', icon: 'shirt', hue: 30 } },
    { x: 66, y: 62, product: { slug: 'field-tote', name: 'Field Tote', price: 120, category: 'bags', icon: 'bag', hue: 18 } },
    { x: 24, y: 76, product: { slug: 'kiln-mug', name: 'Kiln Mug', price: 32, category: 'objects', icon: 'mug', hue: 14 } },
  ],
  currency = 'EUR',
  locale = 'en-GB',
  cartKey = 'ack-cart',
  variant = 'image-left',
  id = 'lookbook',
  className = '',
}: ShopLookbookProps) {
  const money = { currency, locale };
  const parts = title.split(/\*(.+?)\*/g);
  const total = spots.reduce((n, s) => n + s.product.price, 0);

  // 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<HTMLElement>('[data-lookbook]:not([data-bound])').forEach((lb) => {
        lb.dataset.bound = '';
        const dots = Array.from(lb.querySelectorAll<HTMLButtonElement>('[data-spot]'));
        const rows = Array.from(lb.querySelectorAll<HTMLElement>('[data-row]'));
        const close = () => {
          dots.forEach((d) => {
            d.setAttribute('aria-expanded', 'false');
            const p = document.getElementById(d.getAttribute('aria-controls') || '');
            if (p) p.hidden = true;
          });
          rows.forEach((r) => r.classList.remove('is-active'));
        };
        dots.forEach((d, i) => {
          d.addEventListener('click', (e) => {
            e.stopPropagation();
            const open = d.getAttribute('aria-expanded') !== 'true';
            close();
            if (!open) return;
            d.setAttribute('aria-expanded', 'true');
            const p = document.getElementById(d.getAttribute('aria-controls') || '');
            if (p) p.hidden = false;
            rows[i]?.classList.add('is-active');
          });
          rows[i]?.addEventListener('mouseenter', () => d.classList.add('is-hint'));
          rows[i]?.addEventListener('mouseleave', () => d.classList.remove('is-hint'));
        });
        document.addEventListener('click', (e) => { if (!lb.contains(e.target as Node)) close(); });
        lb.addEventListener('keydown', (e) => {
          if (e.key !== 'Escape') return;
          const open = dots.find((d) => d.getAttribute('aria-expanded') === 'true');
          close();
          open?.focus();
        });
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className="slb-cq" data-rk-e29b78="">
      <section className={cx(['slb', `slb--${variant}`, className])} data-lookbook data-rk-e29b78="">
        <div className="slb__stage" data-rk-e29b78="">
          <ProductMedia image={media.image} alt={media.alt ?? ''} icon={media.icon} hue={media.hue} ratio="4 / 5" variant="soft" size="lg" />
          {spots.map((s, i) => (
            <div key={i} className="slb__spot" style={st(`left: ${s.x}%; top: ${s.y}%`)} data-rk-e29b78="">
              <button type="button" className="slb__dot" aria-expanded="false" aria-controls={`${id}-pop-${i}`} aria-label={`${i + 1}: ${s.product.name}, ${formatMoney(s.product.price, money)}`} data-spot={i} data-rk-e29b78="">{i + 1}</button>
              <div className={cx(['slb__pop', { 'slb__pop--left': s.x > 60 }])} id={`${id}-pop-${i}`} hidden data-pop data-rk-e29b78="">
                <p className="slb__pop-name" data-rk-e29b78="">{s.product.name}</p>
                <p className="slb__pop-price" data-rk-e29b78="">{formatMoney(s.product.price, money)}</p>
                <div className="slb__pop-actions" data-rk-e29b78="">
                  <a href={s.href ?? '#'} data-rk-e29b78="">View</a>
                  <button type="button" data-add-to-cart={JSON.stringify({ id: s.product.slug, name: s.product.name, price: s.product.price, icon: s.product.icon, hue: s.product.hue, variant: s.product.colors?.[0]?.name })} data-cart-key={cartKey} data-open-cart="false" data-added-label="Added" data-rk-e29b78=""><span data-label data-rk-e29b78="">Add to bag</span></button>
                </div>
              </div>
            </div>
          ))}
        </div>
      
        <div className="slb__copy" data-rk-e29b78="">
          {eyebrow && <p className="slb__eyebrow" data-rk-e29b78="">{eyebrow}</p>}
          <h2 className="slb__title" data-rk-e29b78="">{parts.map((p, i) => (i % 2 ? <em data-rk-e29b78="">{p}</em> : p))}</h2>
          {text && <p className="slb__text" data-rk-e29b78="">{text}</p>}
          <ol className="slb__list" data-rk-e29b78="">
            {spots.map((s, i) => (
              <li key={i} className="slb__row" data-row={i} data-rk-e29b78="">
                <span className="slb__row-num" aria-hidden="true" data-rk-e29b78="">{String(i + 1).padStart(2, '0')}</span>
                <a className="slb__row-name" href={s.href ?? '#'} data-rk-e29b78="">{s.product.name}</a>
                <span className="slb__row-price" data-rk-e29b78="">{formatMoney(s.product.price, money)}</span>
              </li>
            ))}
          </ol>
          <p className="slb__total" data-rk-e29b78=""><span data-rk-e29b78="">The complete look</span><strong data-rk-e29b78="">{formatMoney(total, money)}</strong></p>
        </div>
      </section>
      </div>
    </>
  );
}

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

.slb-cq:where([data-rk-e29b78]) { container-type: inline-size; }
.slb:where([data-rk-e29b78]) {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
  gap: clamp(1.5rem, 5vw, 5rem);
  align-items: center;
  color: var(--c-text-1, #fff);
}
.slb--image-right:where([data-rk-e29b78]) .slb__stage:where([data-rk-e29b78]) { order: 2; }
.slb__stage:where([data-rk-e29b78]) { position: relative; border-radius: var(--r-sm, 2px); }
.slb__stage:where([data-rk-e29b78]) > .p-media { border-radius: var(--r-sm, 2px); }
.slb__spot:where([data-rk-e29b78]) { position: absolute; z-index: 2; transform: translate(-50%, -50%); }
.slb__dot:where([data-rk-e29b78]) {
  position: relative;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border: 0;
  border-radius: 50%;
  background: var(--c-text-1, #fff);
  color: var(--c-bg, #050505);
  font: inherit;
  font-size: 0.8rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 10px 30px -8px rgba(0, 0, 0, 0.6);
  transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.slb__dot:where([data-rk-e29b78])::before { content: ''; position: absolute; inset: -6px; border-radius: 50%; border: 1px solid var(--c-text-1, #fff); opacity: 0.6; animation: shop-lookbook-slb-ring 2.4s ease-out infinite; }
.slb__dot:hover:where([data-rk-e29b78]), .slb__dot.is-hint:where([data-rk-e29b78]), .slb__dot[aria-expanded='true']:where([data-rk-e29b78]) { transform: scale(1.12); background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
@keyframes shop-lookbook-slb-ring { 0% { transform: scale(0.8); opacity: 0.7; } 100% { transform: scale(1.5); opacity: 0; } }

.slb__pop:where([data-rk-e29b78]) {
  position: absolute;
  top: 50%;
  left: calc(100% + 12px);
  width: 220px;
  padding: var(--sp-4, 1rem);
  transform: translateY(-50%);
  background: var(--c-bg-1, #0a0a0a);
  border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12));
  box-shadow: 0 24px 50px -20px rgba(0, 0, 0, 0.6);
  animation: shop-lookbook-slb-pop 0.2s ease-out;
}
.slb__pop--left:where([data-rk-e29b78]) { left: auto; right: calc(100% + 12px); }
.slb__pop[hidden]:where([data-rk-e29b78]) { display: none; }
@keyframes shop-lookbook-slb-pop { from { opacity: 0; transform: translateY(-46%); } }
.slb__pop-name:where([data-rk-e29b78]) { margin: 0; font-family: var(--font-display, Georgia, serif); font-size: 1.05rem; }
.slb__pop-price:where([data-rk-e29b78]) { margin: 2px 0 var(--sp-3, 0.75rem); font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
.slb__pop-actions:where([data-rk-e29b78]) { display: flex; gap: var(--sp-2, 0.5rem); }
.slb__pop-actions:where([data-rk-e29b78]) a:where([data-rk-e29b78]), .slb__pop-actions:where([data-rk-e29b78]) button:where([data-rk-e29b78]) {
  flex: 1;
  display: inline-grid;
  place-items: center;
  min-height: 40px;
  border: 1px solid var(--c-text-1, #fff);
  border-radius: var(--r-sm, 2px);
  background: transparent;
  color: var(--c-text-1, #fff);
  font: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
}
.slb__pop-actions:where([data-rk-e29b78]) button:where([data-rk-e29b78]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
.slb__pop-actions:where([data-rk-e29b78]) button.is-added:where([data-rk-e29b78]) { background: var(--shop-accent, var(--c-primary-light, #d4a017)); border-color: transparent; color: var(--c-on-primary, #050505); }
.slb:where([data-rk-e29b78]) :is(a, button):focus-visible:where([data-rk-e29b78]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }

.slb__eyebrow:where([data-rk-e29b78]) { margin: 0 0 var(--sp-3, 0.75rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.slb__title:where([data-rk-e29b78]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2.4rem, 5.5vw, 4.5rem); font-weight: 400; letter-spacing: -0.04em; line-height: 0.95; }
.slb__title:where([data-rk-e29b78]) em:where([data-rk-e29b78]) { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.slb__text:where([data-rk-e29b78]) { max-width: 28rem; margin: var(--sp-5, 1.25rem) 0 0; line-height: 1.65; color: var(--c-text-2, #a3a3a3); }
.slb__list:where([data-rk-e29b78]) { margin: var(--sp-8, 2rem) 0 0; padding: 0; list-style: none; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); }
.slb__row:where([data-rk-e29b78]) { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: var(--sp-3, 0.75rem); min-height: 56px; border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); transition: background 0.2s ease, padding 0.2s ease; }
.slb__row.is-active:where([data-rk-e29b78]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); padding-inline: var(--sp-2, 0.5rem); }
.slb__row-num:where([data-rk-e29b78]) { font-size: 0.75rem; color: var(--c-text-2, #a3a3a3); font-variant-numeric: tabular-nums; }
.slb__row-name:where([data-rk-e29b78]) { font-family: var(--font-display, Georgia, serif); font-size: 1.15rem; color: inherit; text-decoration: none; }
.slb__row-name:hover:where([data-rk-e29b78]) { color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.slb__row-price:where([data-rk-e29b78]) { font-variant-numeric: tabular-nums; }
.slb__total:where([data-rk-e29b78]) { display: flex; justify-content: space-between; margin: var(--sp-4, 1rem) 0 0; font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
.slb__total:where([data-rk-e29b78]) strong:where([data-rk-e29b78]) { font-size: 1.1rem; color: var(--c-text-1, #fff); }

@container (max-width: 860px) {
  .slb:where([data-rk-e29b78]) { grid-template-columns: 1fr; }
  .slb--image-right:where([data-rk-e29b78]) .slb__stage:where([data-rk-e29b78]) { order: 0; }
  .slb__pop:where([data-rk-e29b78]) { left: 50%; top: calc(100% + 10px); transform: translateX(-50%); width: 200px; }
  .slb__pop--left:where([data-rk-e29b78]) { right: auto; left: 50%; }
  @keyframes shop-lookbook-slb-pop { from { opacity: 0; } }
}
@media (prefers-reduced-motion: reduce) {
  .slb__dot:where([data-rk-e29b78])::before, .slb__pop:where([data-rk-e29b78]) { animation: none; }
}