Produktbild
Gezeichnete Produktmotive in weichem Studiolicht, dazu die Shop-Teile, die jede E-Commerce-Component teilt: Preis, Bewertung, Menge, Farbfelder, Größen und Steuerhinweis.
Variants
Details
Was Produktbild mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Products
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.1.0 · seit 1.0.0
- Datei
- components/ui/product-image.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 11 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ProductImageProps aus components/ui/product-image.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
motifPflichtProductMotifmugbowlvasecandlelamppouchbottlebackpacksneakerheadphoneswatchtote
toneoptionalProductTone | (string & {})Main color: a key of PRODUCT_TONES or any CSS color
detailoptionalProductTone | (string & {})Second color: clay foot, bottle cap, watch case, sneaker sole
viewoptionalProductViewfrontsideclosescene
front, side (mirrored, light from the right), close (detail crop), scene (on a plinth)
backdropoptionalProductBackdropsofttintedplain
soft: neutral surface. tinted: a pale wash of the product color. plain: no background.
zoomoptionalnumberScale the product inside its frame, 0.8 leaves more room around it
shiftYoptionalnumberMove the product down (positive) or up, in percent of the frame height
shiftXoptionalnumberMove the product right (positive) or left, in percent of the frame width
textoptionalstringShort text on the label, engraving or dial
labeloptionalstringAlt text. Without it the image is decorative.
Eingebaute Texte
Diese Texte schreibt Produktbild selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 11 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| quantity | Englisch Quantity | Deutsch Menge |
| decrease | Englisch Decrease quantity | Deutsch Menge verringern |
| increase | Englisch Increase quantity | Deutsch Menge erhöhen |
| outOf | Englisch out of 5 stars | Deutsch von 5 Sternen |
| reviews | Englisch reviews | Deutsch Bewertungen |
| salePrice | Englisch Sale price | Deutsch Angebotspreis |
| regularPrice | Englisch Regular price | Deutsch Regulärer Preis |
| taxIncl | Englisch Incl. VAT, plus shipping | Deutsch inkl. MwSt., zzgl. Versand |
| taxInclFree | Englisch Incl. VAT, free shipping | Deutsch inkl. MwSt., versandkostenfrei |
| color | Englisch Color | Deutsch Farbe |
| soldOut | Englisch sold out | Deutsch ausverkauft |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, img, label und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.016 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
/*
* Shop foundation for the ecommerce blocks.
*
* ProductImage drawn product motifs on soft light, for demos and placeholders.
* In a real shop pass your own <img> into the blocks instead.
* ProductMedia the frame every product image sits in (radius, crop, surface).
* formatPrice prices in the format of the language: 24,90 € / €24.90.
* Price, Rating, Quantity, SwatchGroup, TaxNote small parts all shop blocks share.
*/
/* ------------------------------------------------------------------ */
/* Types shared by the shop blocks */
/* ------------------------------------------------------------------ */
export interface ShopColor {
name: string;
/** Any CSS color for the swatch, e.g. a value from PRODUCT_TONES */
color: string;
/** Optional image for this color, shown when the color is picked */
image?: React.ReactNode;
}
/** A size option. Plain strings work too, objects can be sold out. */
export interface ShopSize {
label: string;
/** Shown struck through and cannot be picked */
soldOut?: boolean;
}
/** Sizes as plain strings or objects, always as objects */
export const sizesOf = (sizes?: (string | ShopSize)[]): ShopSize[] => (sizes ?? []).map((s) => (typeof s === 'string' ? { label: s } : s));
/** The size to preselect: the middle one that is in stock */
export function defaultSize(sizes?: (string | ShopSize)[], prefer = 0.5): string {
const all = sizesOf(sizes);
const open = all.filter((s) => !s.soldOut);
return open[Math.min(open.length - 1, Math.floor((open.length - 1) * prefer))]?.label ?? '';
}
export interface ShopProduct {
id: string;
name: string;
href?: string;
price: number;
/** Former price, shows the price as reduced */
compareAt?: number;
/** Main image, any node: <img>, <ProductImage>, … */
image: React.ReactNode;
/** Second image, shown on hover in grids */
hoverImage?: React.ReactNode;
/** All images for galleries, the first one is the main image */
images?: React.ReactNode[];
/** One short line under the name: color, material, size */
subtitle?: string;
description?: string;
/** Small label on the image: New, Bestseller, -20 % */
badge?: string;
category?: string;
rating?: number;
reviews?: number;
colors?: ShopColor[];
/** Sizes as strings, or as { label, soldOut } to mark single sizes as sold out */
sizes?: (string | ShopSize)[];
soldOut?: boolean;
}
export interface LineItem {
id: string;
name: string;
href?: string;
/** Picked options, e.g. "Sage · 350 ml" */
variant?: string;
/** Unit price, gross */
price: number;
compareAt?: number;
quantity: number;
image: React.ReactNode;
}
/* ------------------------------------------------------------------ */
/* Built-in texts */
/* ------------------------------------------------------------------ */
const TEXTS = {
en: {
quantity: 'Quantity',
decrease: 'Decrease quantity',
increase: 'Increase quantity',
outOf: 'out of 5 stars',
reviews: 'reviews',
salePrice: 'Sale price',
regularPrice: 'Regular price',
taxIncl: 'Incl. VAT, plus shipping',
taxInclFree: 'Incl. VAT, free shipping',
color: 'Color',
soldOut: 'sold out',
},
de: {
quantity: 'Menge',
decrease: 'Menge verringern',
increase: 'Menge erhöhen',
outOf: 'von 5 Sternen',
reviews: 'Bewertungen',
salePrice: 'Angebotspreis',
regularPrice: 'Regulärer Preis',
taxIncl: 'inkl. MwSt., zzgl. Versand',
taxInclFree: 'inkl. MwSt., versandkostenfrei',
color: 'Farbe',
soldOut: 'ausverkauft',
},
};
/* ------------------------------------------------------------------ */
/* Numbers and prices */
/* ------------------------------------------------------------------ */
const intlLocale = (locale: Locale) => (locale === 'de' ? 'de-DE' : 'en-US');
/** 24,90 € in German, €24.90 in English. Whole amounts keep their cents, as shops do. */
export function formatPrice(locale: Locale, value: number, currency = 'EUR') {
return new Intl.NumberFormat(intlLocale(locale), { style: 'currency', currency }).format(value);
}
/** 4,8 / 4.8, 1.204 / 1,204 */
export function formatNumber(locale: Locale, value: number, digits = 0) {
return new Intl.NumberFormat(intlLocale(locale), { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value);
}
/** Percent in the format of the language: 19 % / 19% */
export function formatPercent(locale: Locale, value: number) {
return locale === 'de' ? `${formatNumber(locale, value)} %` : `${formatNumber(locale, value)}%`;
}
/** VAT contained in a gross amount, e.g. 19 % */
export const vatOf = (gross: number, rate = 19) => round((gross * rate) / (100 + rate));
/** A price, with the former price struck through when reduced. */
export function Price({ value, compareAt, currency = 'EUR', size = 'md', className }: { value: number; compareAt?: number; currency?: string; size?: 'sm' | 'md' | 'lg' | 'xl'; className?: string }) {
const locale = useLocale();
const t = useTexts(TEXTS);
const reduced = compareAt !== undefined && compareAt > value;
const SIZE = { sm: 'text-[13px]', md: 'text-[15px]', lg: 'text-[19px]', xl: 'text-[26px] tracking-[-0.02em]' };
return (
<span className={cn('inline-flex flex-wrap items-baseline gap-x-2 tabular-nums', className)}>
{reduced && <span className="sr-only">{t.salePrice}</span>}
<span className={cn('font-semibold text-foreground', SIZE[size], reduced && 'text-[color-mix(in_oklch,var(--destructive)_85%,var(--foreground))]')}>{formatPrice(locale, value, currency)}</span>
{reduced && (
<>
<span className="sr-only">{t.regularPrice}</span>
<s className={cn('text-muted-foreground decoration-[1px]', size === 'xl' ? 'text-[16px]' : size === 'lg' ? 'text-[14px]' : 'text-[13px]')}>{formatPrice(locale, compareAt!, currency)}</s>
</>
)}
</span>
);
}
/** "Incl. VAT, plus shipping" / "inkl. MwSt., zzgl. Versand", with an optional link to the shipping page */
export function TaxNote({ freeShipping, href, className }: { freeShipping?: boolean; href?: string; className?: string }) {
const t = useTexts(TEXTS);
const text = freeShipping ? t.taxInclFree : t.taxIncl;
return (
<p className={cn('text-[12px] text-muted-foreground', className)}>
{href ? (
<a href={href} className="underline decoration-border-strong underline-offset-2 hover:text-foreground">
{text}
</a>
) : (
text
)}
</p>
);
}
/* ------------------------------------------------------------------ */
/* Shop motion */
/* ------------------------------------------------------------------ */
/*
* Small motions the shop blocks share. All of them only start after a
* change in the browser, so the server renders the final state, and all of
* them stop with reduced motion.
*/
const SHOP_CSS = `
@keyframes kitshop-swap{from{opacity:0;transform:translateY(55%)}}
@keyframes kitshop-up{from{opacity:0;transform:translateY(70%)}}
@keyframes kitshop-down{from{opacity:0;transform:translateY(-70%)}}
@keyframes kitshop-fly{0%{opacity:0;transform:translate(-50%,6px) scale(.8)}18%{opacity:1;transform:translate(-50%,-6px) scale(1)}100%{opacity:0;transform:translate(-50%,-38px) scale(.96)}}
@keyframes kitshop-pop{0%{opacity:0;transform:scale(.5)}60%{opacity:1;transform:scale(1.08)}100%{transform:scale(1)}}
@keyframes kitshop-in{from{opacity:0;transform:translateY(12px) scale(.98)}}
@keyframes kitshop-fade{from{opacity:0}}
@keyframes kitshop-zoom{from{opacity:0;transform:translateY(14px) scale(.97)}}
@keyframes kitshop-sheet{from{transform:translateY(40%);opacity:0}}
@keyframes kitshop-next{from{opacity:0;transform:translateX(24px)}}
@keyframes kitshop-prev{from{opacity:0;transform:translateX(-24px)}}
@keyframes kitshop-shake{20%{transform:translateX(-5px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(2px)}}
@keyframes kitshop-draw{from{stroke-dashoffset:var(--kitshop-len,24)}}
@media (prefers-reduced-motion:reduce){[class*="kitshop-"]{animation:none!important}}`;
/** The keyframes of the shop motions. React hoists it once into the head. */
export function ShopMotion() {
return (
<style href="kitshop-motion" precedence="default">
{SHOP_CSS}
</style>
);
}
/**
* A number that glides from its last value to the new one, for totals and
* subtotals. Starts at the value itself, so server and browser agree.
*/
export function useTween(value: number, ms = 650) {
const [shown, setShown] = useState(value);
const from = useRef(value);
useEffect(() => {
const start = from.current;
if (start === value) return;
if (prefersReducedMotion()) {
from.current = value;
setShown(value);
return;
}
let raf = 0;
const t0 = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - t0) / ms);
const v = t >= 1 ? value : start + (value - start) * (1 - Math.pow(1 - t, 3));
from.current = v;
setShown(v);
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [value, ms]);
return shown;
}
/** A price that counts to its new value when it changes (cart totals). Screen readers get the final value. */
export function TweenPrice({ value, currency = 'EUR', className }: { value: number; currency?: string; className?: string }) {
const locale = useLocale();
const shown = useTween(value);
return (
<span className={cn('tabular-nums', className)}>
<span className="sr-only">{formatPrice(locale, value, currency)}</span>
<span aria-hidden>{formatPrice(locale, shown, currency)}</span>
</span>
);
}
/** true once the value has changed after the first render */
function useChanged(value: unknown) {
const first = useRef(value);
const [changed, setChanged] = useState(false);
useEffect(() => {
if (value !== first.current) setChanged(true);
}, [value]);
return changed;
}
/**
* Swaps its content with a short rise whenever `value` changes, for button
* labels like "Add to cart" turning into "Added".
*/
export function Swap({ value, children, className }: { value: string | number | boolean; children: React.ReactNode; className?: string }) {
const changed = useChanged(value);
return (
<>
<ShopMotion />
<span key={String(value)} className={cn('inline-flex items-center gap-[inherit]', changed && 'animate-[kitshop-swap_0.42s_var(--ease-kit)]', className)}>
{children}
</span>
</>
);
}
/**
* "Added" feedback that resets itself. `count` rises with every add, pass it
* to <FlyUp> so a small "+1" rises from the button each time.
*/
export function useAddFlash(ms = 1600) {
const [state, setState] = useState<{ id: string | null; count: number }>({ id: null, count: 0 });
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const flash = (id: string) => {
setState((s) => ({ id, count: s.count + 1 }));
clearTimeout(timer.current);
timer.current = setTimeout(() => setState((s) => ({ ...s, id: null })), ms);
};
return [state.id, flash, state.count] as const;
}
/** A "+1" that rises from its parent (which needs `relative`) and fades, once per `count`. */
export function FlyUp({ count, label = '+1', className }: { count: number; label?: string; className?: string }) {
if (!count) return null;
return (
<>
<ShopMotion />
<span
key={count}
aria-hidden
className={cn(
'pointer-events-none absolute left-1/2 top-0 z-20 grid h-6 min-w-6 place-items-center rounded-full bg-primary px-1.5 text-[11px] font-semibold tabular-nums text-primary-foreground opacity-0 shadow-[var(--shadow-md)]',
'animate-[kitshop-fly_0.9s_var(--ease-kit)_both] motion-reduce:hidden',
className,
)}
>
{label}
</span>
</>
);
}
/* ------------------------------------------------------------------ */
/* Rating */
/* ------------------------------------------------------------------ */
const STAR = 'm12 2.8 2.76 5.6 6.18.9-4.47 4.36 1.05 6.15L12 16.9l-5.52 2.91 1.05-6.15L3.06 9.3l6.18-.9Z';
function StarRow({ size, className }: { size: number; className?: string }) {
return (
<span className={cn('flex gap-0.5', className)} aria-hidden>
{[0, 1, 2, 3, 4].map((i) => (
<svg key={i} viewBox="0 0 24 24" width={size} height={size} className="shrink-0" fill="currentColor">
<path d={STAR} />
</svg>
))}
</span>
);
}
/** Five stars with partial fill. With count it adds "(128)" or the full label. */
export function Rating({ value, count, size = 14, showValue, href, className }: { value: number; count?: number; size?: number; showValue?: boolean; href?: string; className?: string }) {
const locale = useLocale();
const t = useTexts(TEXTS);
const pct = round(Math.max(0, Math.min(5, value)) * 20, 1);
const label = `${formatNumber(locale, value, 1)} ${t.outOf}${count !== undefined ? `, ${formatNumber(locale, count)} ${t.reviews}` : ''}`;
const Tag = href ? 'a' : 'span';
return (
<Tag {...(href ? { href } : {})} className={cn('inline-flex items-center gap-2 text-[13px] text-muted-foreground', href && 'hover:text-foreground', className)}>
<span className="relative inline-flex" role="img" aria-label={label}>
<StarRow size={size} className="text-[color-mix(in_oklch,var(--foreground)_14%,transparent)]" />
<span data-clip-ok className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${pct}%` }}>
<StarRow size={size} className="text-foreground" />
</span>
</span>
{showValue && <span className="font-medium tabular-nums text-foreground" aria-hidden>{formatNumber(locale, value, 1)}</span>}
{count !== undefined && <span className="tabular-nums" aria-hidden>({formatNumber(locale, count)})</span>}
</Tag>
);
}
/* ------------------------------------------------------------------ */
/* Quantity */
/* ------------------------------------------------------------------ */
/** Minus, number, plus. The buttons keep a 44 px hit area even in the small size. */
export function Quantity({ value, onChange, min = 1, max = 99, size = 'md', label, className }: { value: number; onChange: (n: number) => void; min?: number; max?: number; size?: 'sm' | 'md'; label?: string; className?: string }) {
const t = useTexts(TEXTS);
// The direction of the last step, so the number rolls up or down
const [dir, setDir] = useState<0 | 1 | -1>(0);
const btn = cn(
'relative grid place-items-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-35',
'after:absolute after:-inset-1 after:content-[""]',
// The small stepper keeps 44 px buttons on touch phones, the group clips any outside hit area
size === 'sm' ? 'size-8 max-sm:size-11' : 'size-11',
);
return (
<div role="group" aria-label={label ?? t.quantity} className={cn('inline-flex shrink-0 items-center overflow-hidden rounded-lg border border-border-strong bg-background shadow-[var(--shadow-sm)]', className)}>
<ShopMotion />
<button type="button" className={cn(btn, 'active:[&>svg]:scale-75 [&>svg]:transition-transform')} onClick={() => { setDir(-1); onChange(Math.max(min, value - 1)); }} disabled={value <= min} aria-label={t.decrease}>
<Icon name="minus" size={size === 'sm' ? 14 : 16} />
</button>
<output aria-live="polite" className={cn('min-w-7 overflow-hidden text-center font-medium tabular-nums text-foreground [clip-path:inset(0)]', size === 'sm' ? 'text-[13px]' : 'min-w-9 text-[15px]')}>
<span key={value} className={cn('inline-block', dir === 1 && 'animate-[kitshop-up_0.32s_var(--ease-kit)]', dir === -1 && 'animate-[kitshop-down_0.32s_var(--ease-kit)]')}>
{value}
</span>
</output>
<button type="button" className={cn(btn, 'active:[&>svg]:scale-75 [&>svg]:transition-transform')} onClick={() => { setDir(1); onChange(Math.min(max, value + 1)); }} disabled={value >= max} aria-label={t.increase}>
<Icon name="plus" size={size === 'sm' ? 14 : 16} />
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Color swatches */
/* ------------------------------------------------------------------ */
/** A radio group of color dots. Hit area 44 px, the dot itself 28 or 22 px. */
export function SwatchGroup({ colors, value, onChange, name, size = 'md', label, className }: { colors: ShopColor[]; value: string; onChange?: (name: string) => void; name: string; size?: 'sm' | 'md'; label?: string; className?: string }) {
const t = useTexts(TEXTS);
const group = useRef<HTMLDivElement>(null);
// One ring that glides to the picked color. Until it is measured (server,
// no JavaScript) every swatch draws its own ring.
const [ring, setRing] = useState<{ x: number; y: number; ready: boolean } | null>(null);
const index = colors.findIndex((c) => c.name === value);
useEffect(() => {
const el = group.current;
if (!el) return;
const update = () => {
const lab = el.children[index] as HTMLElement | undefined;
if (!lab || lab.tagName !== 'LABEL') return setRing(null);
setRing((r) => ({ x: lab.offsetLeft, y: lab.offsetTop, ready: !!r }));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [index]);
return (
<div ref={group} role="radiogroup" aria-label={label ?? t.color} className={cn('relative flex flex-wrap', size === 'sm' ? '-m-1.5' : '-m-2', className)}>
{colors.map((c) => {
const on = c.name === value;
return (
<label key={c.name} className={cn('group/sw relative grid cursor-pointer place-items-center rounded-full', size === 'sm' ? 'size-8' : 'size-11')} title={c.name}>
<input type="radio" name={name} value={c.name} checked={on} onChange={() => onChange?.(c.name)} className="peer sr-only" />
<span className="sr-only">{c.name}</span>
<span
aria-hidden
className={cn(
'block rounded-full shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--foreground)_14%,transparent),inset_0_6px_8px_-4px_rgb(255_255_255/0.35)] transition-[box-shadow,transform] duration-200 group-hover/sw:scale-105',
size === 'sm' ? 'size-[18px]' : 'size-7',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-[var(--ring)]',
)}
style={{ background: c.color }}
/>
<span aria-hidden className={cn('pointer-events-none absolute rounded-full border-[1.5px] transition-colors', size === 'sm' ? 'size-[26px]' : 'size-9', on && !ring ? 'border-foreground' : 'border-transparent')} />
</label>
);
})}
{ring && (
<span
aria-hidden
className={cn('pointer-events-none absolute left-0 top-0 grid place-items-center', size === 'sm' ? 'size-8' : 'size-11', ring.ready && 'transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
style={{ transform: `translate3d(${ring.x}px, ${ring.y}px, 0)` }}
>
<span className={cn('rounded-full border-[1.5px] border-foreground', size === 'sm' ? 'size-[26px]' : 'size-9')} />
</span>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sizes */
/* ------------------------------------------------------------------ */
/**
* Size buttons as a radio group. Sold out sizes stay visible, struck
* through and disabled, so nobody wonders whether the size exists.
* Put it inside a <fieldset> with a <legend>.
*/
export function SizeGroup({ sizes, value, onChange, name, className }: { sizes: (string | ShopSize)[]; value: string; onChange: (label: string) => void; name: string; className?: string }) {
const t = useTexts(TEXTS);
return (
<div className={cn('grid grid-cols-4 gap-2 sm:grid-cols-7', className)}>
{sizesOf(sizes).map((s) => {
const out = !!s.soldOut;
const on = value === s.label && !out;
return (
<label
key={s.label}
title={out ? `${s.label}, ${t.soldOut}` : undefined}
className={cn(
'relative grid h-11 place-items-center overflow-hidden rounded-lg border text-[13px] font-medium tabular-nums transition-[border-color,background-color,color] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
out && 'cursor-not-allowed border-border bg-surface text-subtle-foreground',
!out && (on ? 'cursor-pointer border-foreground bg-foreground text-background' : 'cursor-pointer border-border-strong bg-background text-foreground hover:border-[color-mix(in_oklab,var(--foreground)_45%,var(--border-strong))]'),
)}
>
<input type="radio" name={name} value={s.label} checked={on} disabled={out} onChange={() => onChange(s.label)} className="sr-only" />
{s.label}
{out && (
<>
<span className="sr-only">, {t.soldOut}</span>
<svg aria-hidden className="absolute inset-0 size-full text-border-strong" preserveAspectRatio="none" viewBox="0 0 100 100">
<path d="M0 100 100 0" stroke="currentColor" strokeWidth="1" vectorEffect="non-scaling-stroke" />
</svg>
</>
)}
</label>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Product images */
/* ------------------------------------------------------------------ */
/**
* Product colors for the drawn motifs and for swatches. These are product
* colors, like the pixels of a photo, so they stay the same in both modes.
* `accent` follows the project's accent, `ink` and `paper` follow the theme.
*/
export const PRODUCT_TONES = {
clay: 'oklch(0.66 0.1 42)',
sand: 'oklch(0.84 0.045 78)',
kraft: 'oklch(0.72 0.07 68)',
cream: 'oklch(0.94 0.022 85)',
sage: 'oklch(0.72 0.05 152)',
forest: 'oklch(0.46 0.06 162)',
ocean: 'oklch(0.56 0.085 236)',
mist: 'oklch(0.82 0.03 230)',
stone: 'oklch(0.76 0.008 260)',
graphite: 'oklch(0.36 0.012 260)',
ochre: 'oklch(0.76 0.12 78)',
berry: 'oklch(0.52 0.12 12)',
blush: 'oklch(0.85 0.05 30)',
accent: 'var(--primary)',
ink: 'color-mix(in oklch, var(--foreground) 88%, var(--background))',
paper: 'color-mix(in oklch, var(--background) 92%, var(--foreground))',
} as const;
export type ProductTone = keyof typeof PRODUCT_TONES;
export type ProductMotif = 'mug' | 'bowl' | 'vase' | 'candle' | 'lamp' | 'pouch' | 'bottle' | 'backpack' | 'sneaker' | 'headphones' | 'watch' | 'tote';
export const PRODUCT_MOTIFS: ProductMotif[] = ['mug', 'bowl', 'vase', 'candle', 'lamp', 'pouch', 'bottle', 'backpack', 'sneaker', 'headphones', 'watch', 'tote'];
/** Second color per motif: raw clay under the glaze, the cap of a bottle, the case of a watch */
const DETAIL: Record<ProductMotif, ProductTone> = {
mug: 'sand',
bowl: 'sand',
vase: 'kraft',
candle: 'cream',
lamp: 'stone',
pouch: 'cream',
bottle: 'graphite',
backpack: 'kraft',
sneaker: 'cream',
headphones: 'stone',
watch: 'stone',
tote: 'graphite',
};
const toneValue = (tone: ProductTone | string) => (tone in PRODUCT_TONES ? PRODUCT_TONES[tone as ProductTone] : tone);
const shade = (c: string, pct: number) => `color-mix(in oklch, ${c} ${pct}%, black)`;
const light = (c: string, pct: number) => `color-mix(in oklch, ${c} ${pct}%, white)`;
const A = 'var(--pi)';
const B = 'var(--pi2)';
/** Gradients shared by all motifs, one set per image so ids never collide */
function Defs({ p }: { p: string }) {
const cyl = (id: string, c: string) => (
<linearGradient id={p + id} x1="0" x2="1" y1="0" y2="0">
<stop offset="0" style={{ stopColor: shade(c, 58) }} />
<stop offset="0.14" style={{ stopColor: shade(c, 84) }} />
<stop offset="0.33" style={{ stopColor: c }} />
<stop offset="0.43" style={{ stopColor: light(c, 74) }} />
<stop offset="0.56" style={{ stopColor: c }} />
<stop offset="0.86" style={{ stopColor: shade(c, 80) }} />
<stop offset="1" style={{ stopColor: shade(c, 64) }} />
</linearGradient>
);
const vert = (id: string, c: string) => (
<linearGradient id={p + id} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" style={{ stopColor: light(c, 84) }} />
<stop offset="0.5" style={{ stopColor: c }} />
<stop offset="1" style={{ stopColor: shade(c, 80) }} />
</linearGradient>
);
return (
<defs>
{cyl('c', A)}
{cyl('d', B)}
{vert('v', A)}
{vert('w', B)}
<linearGradient id={p + 'in'} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" style={{ stopColor: shade(A, 55) }} />
<stop offset="1" style={{ stopColor: shade(A, 85) }} />
</linearGradient>
<linearGradient id={p + 'g'} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0.55" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</linearGradient>
<radialGradient id={p + 'f'}>
<stop offset="0" stopColor="#000" style={{ stopOpacity: 'var(--pi-floor)' }} />
<stop offset="1" stopColor="#000" stopOpacity="0" />
</radialGradient>
<radialGradient id={p + 'flame'} cx="0.5" cy="0.7" r="0.6">
<stop offset="0" stopColor="oklch(0.98 0.06 95)" />
<stop offset="0.55" stopColor="oklch(0.86 0.15 80)" />
<stop offset="1" stopColor="oklch(0.7 0.18 50)" />
</radialGradient>
<radialGradient id={p + 'warm'}>
<stop offset="0" stopColor="oklch(0.9 0.13 85)" stopOpacity="0.55" />
<stop offset="1" stopColor="oklch(0.9 0.13 85)" stopOpacity="0" />
</radialGradient>
<linearGradient id={p + 'cone'} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="oklch(0.95 0.09 88)" stopOpacity="0.5" />
<stop offset="1" stopColor="oklch(0.95 0.09 88)" stopOpacity="0" />
</linearGradient>
<filter id={p + 'b'} x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="9" />
</filter>
</defs>
);
}
/** A soft shadow on the floor, wide and faint, with a tighter contact shadow */
function Floor({ p, cx = 200, rx }: { p: string; cx?: number; rx: number }) {
return (
<g aria-hidden>
<ellipse cx={cx} cy={318} rx={rx * 1.5} ry={15} fill={`url(#${p}f)`} />
<ellipse cx={cx} cy={317} rx={rx * 0.95} ry={5} fill={`url(#${p}f)`} />
</g>
);
}
type Draw = (p: string, text?: string) => React.ReactNode;
const u = (p: string, id: string) => `url(#${p}${id})`;
const SPECKS = [
[152, 236], [171, 252], [196, 241], [224, 258], [247, 236], [181, 274], [214, 280], [139, 222], [262, 226], [204, 226], [233, 271], [161, 262],
];
const MOTIFS: Record<ProductMotif, { floor?: { cx?: number; rx: number }; draw: Draw }> = {
mug: {
floor: { cx: 196, rx: 64 },
draw: (p) => (
<>
<path d="M246 182c46-4 50 78 2 80" fill="none" stroke={shade(A, 78)} strokeWidth="17" strokeLinecap="round" />
<path d="M246 182c46-4 50 78 2 80" fill="none" stroke={A} strokeWidth="11" strokeLinecap="round" />
<path d="M138 150h110v146a20 20 0 0 1-20 20h-70a20 20 0 0 1-20-20Z" fill={u(p, 'd')} />
<path d="M138 150h110v132c-6 4-12 6-18 2s-12-4-18 2-14 4-20 0-12-5-18 1-12 3-18-1-12-2-18 0Z" fill={u(p, 'c')} />
<rect x="156" y="164" width="9" height="104" rx="4.5" fill={u(p, 'g')} opacity="0.7" />
<ellipse cx="193" cy="150" rx="55" ry="12" style={{ fill: light(A, 70) }} />
<ellipse cx="193" cy="151.5" rx="48.5" ry="8.5" fill={u(p, 'in')} />
</>
),
},
bowl: {
floor: { rx: 70 },
draw: (p) => (
<>
<path d="M172 296h56v14a6 6 0 0 1-6 6h-44a6 6 0 0 1-6-6Z" fill={u(p, 'd')} />
<path d="M106 222h188c0 46-36 80-94 80s-94-34-94-80Z" fill={u(p, 'c')} />
{SPECKS.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={i % 3 ? 1.2 : 1.7} style={{ fill: shade(A, 55) }} opacity="0.45" />
))}
<path d="M130 236c10 26 30 44 52 50" fill="none" stroke="#fff" strokeOpacity="0.28" strokeWidth="5" strokeLinecap="round" />
<ellipse cx="200" cy="222" rx="94" ry="21" style={{ fill: light(A, 72) }} />
<ellipse cx="200" cy="223.5" rx="86" ry="16.5" fill={u(p, 'in')} />
<ellipse cx="200" cy="228" rx="58" ry="8" style={{ fill: shade(A, 72) }} opacity="0.6" />
</>
),
},
vase: {
floor: { rx: 46 },
draw: (p) => (
<>
<g fill="none" strokeLinecap="round" style={{ stroke: shade(B, 70) }} strokeWidth="2">
<path d="M199 122c-4-34-14-56-32-78" />
<path d="M202 122c6-30 18-50 36-64" />
<path d="M200 122c0-24 2-44 6-60" />
</g>
<g style={{ fill: B }}>
{[
[176, 54, -40], [168, 66, -60], [181, 72, -35], [173, 84, -55], [186, 90, -30],
[230, 62, 40], [224, 74, 55], [216, 82, 35], [212, 94, 60],
[205, 66, 10], [204, 80, -12], [203, 94, 14],
].map(([x, y, r], i) => (
<ellipse key={i} cx={x} cy={y} rx="3.4" ry="7.5" transform={`rotate(${r} ${x} ${y})`} />
))}
</g>
<path d="M182 118h36c-1 6-3 9-4 12 0 30 56 60 56 118 0 40-22 60-34 68h-72c-12-8-34-28-34-68 0-58 56-88 56-118-1-3-3-6-4-12Z" fill={u(p, 'c')} />
<g fill="none" stroke="#000" strokeOpacity="0.07">
<path d="M140 220c36 8 84 8 120 0" />
<path d="M132 252c42 8 94 8 136 0" />
<path d="M136 284c40 8 88 8 128 0" />
</g>
<path d="M156 200c-14 22-16 52-6 78" fill="none" stroke="#fff" strokeOpacity="0.3" strokeWidth="6" strokeLinecap="round" />
<ellipse cx="200" cy="118" rx="18" ry="4" style={{ fill: shade(A, 50) }} />
</>
),
},
candle: {
floor: { rx: 64 },
draw: (p, text) => (
<>
<circle cx="200" cy="168" r="44" fill={u(p, 'warm')} />
<path d="M142 196h116v104a16 16 0 0 1-16 16h-84a16 16 0 0 1-16-16Z" fill={u(p, 'c')} />
<rect x="158" y="208" width="8" height="96" rx="4" fill={u(p, 'g')} opacity="0.55" />
<ellipse cx="200" cy="196" rx="58" ry="11" style={{ fill: light(A, 70) }} />
<ellipse cx="200" cy="198.5" rx="52" ry="8" fill={u(p, 'w')} />
<path d="M200 199v-12" style={{ stroke: shade(B, 30) }} strokeWidth="2.5" strokeLinecap="round" />
<path d="M200 146c11 15 13 28 0 40-13-12-11-25 0-40Z" fill={u(p, 'flame')} />
<rect x="166" y="232" width="68" height="50" rx="3" style={{ fill: B }} opacity="0.94" />
{text ? (
<text x="200" y="262" textAnchor="middle" fontSize="13" fontWeight="600" letterSpacing="1.5" style={{ fill: shade(B, 30) }}>
{text}
</text>
) : (
<>
<rect x="182" y="250" width="36" height="4" rx="2" style={{ fill: shade(B, 40) }} />
<rect x="188" y="260" width="24" height="3" rx="1.5" style={{ fill: shade(B, 62) }} />
</>
)}
</>
),
},
lamp: {
floor: { rx: 54 },
draw: (p) => (
<>
<path d="M130 196h140l52 120H78Z" fill={u(p, 'cone')} />
<path d="M150 304a10 10 0 0 1 10-8h80a10 10 0 0 1 10 8v4a8 8 0 0 1-8 8h-84a8 8 0 0 1-8-8Z" fill={u(p, 'd')} />
<rect x="196.5" y="190" width="7" height="108" rx="3.5" fill={u(p, 'd')} />
<path d="M162 110h76l34 86H128Z" fill={u(p, 'c')} />
<ellipse cx="200" cy="196" rx="72" ry="10" style={{ fill: 'oklch(0.96 0.07 90)' }} />
<ellipse cx="200" cy="110" rx="38" ry="5.5" style={{ fill: shade(A, 62) }} />
<path d="M166 122c-8 22-16 44-22 64" fill="none" stroke="#fff" strokeOpacity="0.3" strokeWidth="5" strokeLinecap="round" />
</>
),
},
pouch: {
floor: { rx: 66 },
draw: (p, text) => (
<>
<path d="M144 118h112l8 182a16 16 0 0 1-16 16H152a16 16 0 0 1-16-16Z" fill={u(p, 'c')} />
<path d="M144 98h112v22H144Z" style={{ fill: shade(A, 88) }} />
<g style={{ stroke: shade(A, 60) }} strokeOpacity="0.5" strokeWidth="1.2">
{Array.from({ length: 27 }, (_, i) => (
<path key={i} d={`M${148 + i * 4} 101v16`} />
))}
</g>
<path d="M144 120h112" style={{ stroke: shade(A, 60) }} strokeOpacity="0.5" />
<circle cx="200" cy="146" r="7" style={{ fill: shade(A, 70) }} />
<circle cx="200" cy="146" r="3" style={{ fill: shade(A, 45) }} />
<rect x="158" y="176" width="84" height="104" rx="5" style={{ fill: B }} />
<rect x="158" y="176" width="84" height="104" rx="5" fill="none" style={{ stroke: shade(B, 80) }} strokeOpacity="0.5" />
<text x="200" y="214" textAnchor="middle" fontSize="17" fontWeight="650" letterSpacing="-0.3" style={{ fill: shade(B, 22) }}>
{text ?? 'Nº 7'}
</text>
<rect x="178" y="226" width="44" height="3" rx="1.5" style={{ fill: shade(B, 45) }} />
<rect x="184" y="236" width="32" height="3" rx="1.5" style={{ fill: shade(B, 60) }} />
<ellipse cx="200" cy="258" rx="7" ry="10" transform="rotate(-24 200 258)" style={{ fill: shade(B, 45) }} />
<path d="M197 249c5 5-2 12 3 18" fill="none" style={{ stroke: light(B, 60) }} strokeWidth="1.6" strokeLinecap="round" />
</>
),
},
bottle: {
floor: { rx: 40 },
draw: (p, text) => (
<>
<path d="M192 66c0-22 16-22 16 0" fill="none" style={{ stroke: shade(B, 70) }} strokeWidth="6" strokeLinecap="round" />
<path d="M180 72a10 10 0 0 1 10-10h20a10 10 0 0 1 10 10v40h-40Z" fill={u(p, 'd')} />
<rect x="177" y="104" width="46" height="10" rx="3" style={{ fill: shade(B, 70) }} />
<path d="M168 136c0-14 8-20 16-22h32c8 2 16 8 16 22v164a16 16 0 0 1-16 16h-32a16 16 0 0 1-16-16Z" fill={u(p, 'c')} />
<rect x="181" y="134" width="7" height="166" rx="3.5" fill={u(p, 'g')} opacity="0.65" />
<path d="M168 288h64" style={{ stroke: shade(A, 70) }} strokeOpacity="0.5" />
{text && (
<text x="0" y="0" transform="translate(206 226) rotate(-90)" textAnchor="middle" fontSize="12" fontWeight="600" letterSpacing="2.5" style={{ fill: light(A, 30) }} opacity="0.85">
{text}
</text>
)}
</>
),
},
backpack: {
floor: { rx: 72 },
draw: (p) => (
<>
<rect x="124" y="120" width="16" height="170" rx="7" style={{ fill: shade(A, 72) }} />
<rect x="260" y="120" width="16" height="170" rx="7" style={{ fill: shade(A, 62) }} />
<path d="M186 88c0-24 28-24 28 0" fill="none" style={{ stroke: shade(A, 70) }} strokeWidth="7" strokeLinecap="round" />
<path d="M134 120c0-22 18-34 38-34h56c20 0 38 12 38 34v180a16 16 0 0 1-16 16H150a16 16 0 0 1-16-16Z" fill={u(p, 'c')} />
<path d="M134 142c26 14 106 14 132 0" fill="none" style={{ stroke: shade(A, 68) }} strokeWidth="1.6" strokeDasharray="4 4" />
<rect x="185" y="166" width="30" height="22" rx="4" style={{ fill: B }} />
<path d="M150 220a8 8 0 0 1 8-8h84a8 8 0 0 1 8 8v70a12 12 0 0 1-12 12h-76a12 12 0 0 1-12-12Z" style={{ fill: shade(A, 90) }} />
<path d="M150 222c30-6 70-6 100 0" fill="none" style={{ stroke: shade(A, 55) }} strokeWidth="2" />
<rect x="226" y="219" width="7" height="16" rx="2" style={{ fill: B }} />
<rect x="150" y="128" width="9" height="150" rx="4.5" fill={u(p, 'g')} opacity="0.45" />
</>
),
},
sneaker: {
floor: { cx: 214, rx: 126 },
draw: (p) => (
<>
{/* Outsole and a slim midsole with a rocker at the toe */}
<path d="M94 302h234c9 0 14 5 10 10-3 4-8 6-16 6H106c-8 0-12-5-12-11Z" style={{ fill: shade(B, 50) }} />
<path d="M90 291c0-8 6-14 15-14h200c22 0 38 7 42 17 2 7-4 11-14 11H104c-8 0-14-6-14-14Z" fill={u(p, 'w')} />
<path d="M98 284c56-3 150-3 238 2" fill="none" style={{ stroke: shade(B, 72) }} strokeOpacity="0.55" strokeWidth="1.2" strokeDasharray="3 4" />
{/* Upper */}
<path d="M100 279c-8-24-10-48-2-66 5-11 15-17 27-12 14 7 30 9 44 2l14-30c4-8 14-9 20-3l12 14c24 26 58 46 94 58 20 7 30 18 30 37Z" fill={u(p, 'v')} />
{/* Heel counter, toe cap and a side panel in deeper and lighter shades */}
<path d="M100 279c-8-24-9-46-1-63 15 14 27 38 30 63Z" style={{ fill: shade(A, 82) }} />
<path d="M270 237c26 8 52 18 62 28 6 6 6 14 6 14h-72c-4-16-2-30 4-42Z" style={{ fill: shade(A, 86) }} />
<path d="M150 279c10-28 32-50 60-64l30 22c-22 10-40 26-50 42Z" style={{ fill: light(A, 78) }} opacity="0.5" />
{/* Collar lining and pull tab */}
<path d="M101 205c9-9 20-10 33-3 14 7 28 8 38 1" fill="none" style={{ stroke: shade(A, 45) }} strokeWidth="5" strokeLinecap="round" />
<path d="M96 215c-4-12-1-22 6-24l5 2 1 20Z" style={{ fill: B }} />
{/* Eyelet stay and laces */}
<path d="M193 176c18 22 42 40 70 52" fill="none" style={{ stroke: shade(A, 74) }} strokeWidth="12" strokeLinecap="round" />
<g style={{ stroke: light(B, 40) }} strokeWidth="3" strokeLinecap="round">
<path d="M191 188l12-15" />
<path d="M205 200l12-14" />
<path d="M220 211l11-14" />
<path d="M235 220l11-13" />
<path d="M250 228l10-13" />
</g>
<path d="M186 166c-2-12 6-19 15-16l10 10-8 10Z" style={{ fill: light(A, 62) }} />
{/* Light along the top line and the sole */}
<path d="M218 182c26 24 60 42 96 52" fill="none" stroke="#fff" strokeOpacity="0.32" strokeWidth="3" strokeLinecap="round" />
<path d="M112 297h56" stroke="#fff" strokeOpacity="0.55" strokeWidth="2.5" strokeLinecap="round" />
</>
),
},
headphones: {
floor: { rx: 100 },
draw: (p) => (
<>
<path d="M120 232c0-120 160-120 160 0" fill="none" style={{ stroke: shade(B, 70) }} strokeWidth="14" strokeLinecap="round" />
<path d="M120 232c0-120 160-120 160 0" fill="none" style={{ stroke: B }} strokeWidth="9" strokeLinecap="round" />
<path d="M152 146c24-22 72-22 96 0" fill="none" style={{ stroke: shade(A, 85) }} strokeWidth="20" strokeLinecap="round" />
<path d="M156 140c22-16 66-16 88 0" fill="none" style={{ stroke: light(A, 72) }} strokeOpacity="0.5" strokeWidth="3" strokeLinecap="round" />
<rect x="113" y="204" width="14" height="40" rx="5" fill={u(p, 'd')} />
<rect x="273" y="204" width="14" height="40" rx="5" fill={u(p, 'd')} />
<rect x="92" y="226" width="56" height="90" rx="26" fill={u(p, 'c')} />
<rect x="252" y="226" width="56" height="90" rx="26" fill={u(p, 'c')} />
<rect x="140" y="234" width="16" height="74" rx="8" style={{ fill: shade(A, 60) }} />
<rect x="244" y="234" width="16" height="74" rx="8" style={{ fill: shade(A, 60) }} />
<rect x="104" y="240" width="7" height="60" rx="3.5" fill={u(p, 'g')} opacity="0.6" />
<rect x="264" y="240" width="7" height="60" rx="3.5" fill={u(p, 'g')} opacity="0.6" />
</>
),
},
watch: {
draw: (p, text) => {
const ticks = Array.from({ length: 12 }, (_, i) => {
const a = (i / 12) * Math.PI * 2;
const r1 = i % 3 === 0 ? 38 : 42;
return [round(200 + Math.sin(a) * r1), round(200 - Math.cos(a) * r1), round(200 + Math.sin(a) * 47), round(200 - Math.cos(a) * 47), i % 3 === 0] as const;
});
return (
<>
<g filter={u(p, 'b')} opacity="0.5">
<rect x="178" y="80" width="56" height="260" rx="10" fill="#000" style={{ opacity: 'var(--pi-floor)' }} />
<circle cx="208" cy="214" r="66" fill="#000" style={{ opacity: 'var(--pi-floor)' }} />
</g>
<path d="M174 60h52l-5 92h-42Z" style={{ fill: shade(A, 72) }} />
<path d="M179 248h42l5 92h-52Z" style={{ fill: shade(A, 72) }} />
<g style={{ fill: shade(A, 45) }}>
<circle cx="200" cy="290" r="2.5" />
<circle cx="200" cy="306" r="2.5" />
<circle cx="200" cy="322" r="2.5" />
</g>
<rect x="260" y="191" width="12" height="18" rx="3" fill={u(p, 'd')} />
<circle cx="200" cy="200" r="64" fill={u(p, 'd')} />
<circle cx="200" cy="200" r="57" style={{ fill: shade(B, 60) }} />
<circle cx="200" cy="200" r="53" style={{ fill: A }} />
{ticks.map(([x1, y1, x2, y2, major], i) => (
<path key={i} d={`M${x1} ${y1}L${x2} ${y2}`} style={{ stroke: light(A, 20) }} strokeWidth={major ? 3 : 1.5} strokeLinecap="round" />
))}
{text && (
<text x="200" y="232" textAnchor="middle" fontSize="8" fontWeight="600" letterSpacing="1.5" style={{ fill: light(A, 30) }} opacity="0.8">
{text}
</text>
)}
<path d="M200 200 186 177" style={{ stroke: light(A, 15) }} strokeWidth="4" strokeLinecap="round" />
<path d="M200 200 233 186" style={{ stroke: light(A, 15) }} strokeWidth="3" strokeLinecap="round" />
<path d="M200 200 196 236" stroke="oklch(0.68 0.17 40)" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="200" cy="200" r="4" style={{ fill: light(A, 15) }} />
<path d="M156 170a52 52 0 0 1 50-24" fill="none" stroke="#fff" strokeOpacity="0.28" strokeWidth="6" strokeLinecap="round" />
</>
);
},
},
tote: {
floor: { rx: 92 },
draw: (p) => (
<>
<path d="M170 172c0-80 60-80 60 0" fill="none" style={{ stroke: shade(A, 62) }} strokeWidth="10" strokeLinecap="round" />
<path d="M128 168h144l10 140a10 10 0 0 1-10 10H128a10 10 0 0 1-10-10Z" fill={u(p, 'c')} />
<path d="M128 168h144l1 12H127Z" style={{ fill: shade(A, 82) }} />
<path d="M200 182v130" style={{ stroke: shade(A, 80) }} strokeOpacity="0.25" strokeWidth="2" />
<path d="M156 186c0-90 88-90 88 0" fill="none" style={{ stroke: shade(A, 85) }} strokeWidth="11" strokeLinecap="round" />
<path d="M156 186c0-90 88-90 88 0" fill="none" style={{ stroke: light(A, 60) }} strokeOpacity="0.45" strokeWidth="2" strokeDasharray="3 4" />
<rect x="150" y="176" width="13" height="18" rx="2" style={{ fill: shade(A, 78) }} />
<rect x="237" y="176" width="13" height="18" rx="2" style={{ fill: shade(A, 78) }} />
<rect x="186" y="262" width="28" height="18" rx="2" style={{ fill: B }} />
<rect x="140" y="184" width="10" height="120" rx="5" fill={u(p, 'g')} opacity="0.4" />
</>
),
},
};
export type ProductView = 'front' | 'side' | 'close' | 'scene';
export type ProductBackdrop = 'soft' | 'tinted' | 'plain';
export interface ProductImageProps {
motif: ProductMotif;
/** Main color: a key of PRODUCT_TONES or any CSS color */
tone?: ProductTone | (string & {});
/** Second color: clay foot, bottle cap, watch case, sneaker sole */
detail?: ProductTone | (string & {});
/** front, side (mirrored, light from the right), close (detail crop), scene (on a plinth) */
view?: ProductView;
/** soft: neutral surface. tinted: a pale wash of the product color. plain: no background. */
backdrop?: ProductBackdrop;
/** Scale the product inside its frame, 0.8 leaves more room around it */
zoom?: number;
/** Move the product down (positive) or up, in percent of the frame height */
shiftY?: number;
/** Move the product right (positive) or left, in percent of the frame width */
shiftX?: number;
/** Short text on the label, engraving or dial */
text?: string;
/** Alt text. Without it the image is decorative. */
label?: string;
className?: string;
}
const VIEWBOX: Record<ProductView, string> = {
front: '36 36 328 328',
side: '36 36 328 328',
close: '80 70 240 240',
scene: '0 20 400 400',
};
/**
* A drawn product on soft studio light. Fills its parent when the parent
* has a size (as in ProductMedia), otherwise it is square.
*/
export function ProductImage({ motif, tone = 'clay', detail, view = 'front', backdrop = 'soft', zoom = 1, shiftY = 0, shiftX = 0, text, label, className }: ProductImageProps) {
const [vx, vy, vw, vh] = VIEWBOX[view].split(' ').map(Number);
const w = vw / zoom;
const h = vh / zoom;
const viewBox = [vx - (w - vw) / 2 - (shiftX / 100) * w, vy - (h - vh) / 2 - (shiftY / 100) * h, w, h].map((n) => round(n)).join(' ');
const p = 'pi' + useId().replace(/[^a-zA-Z0-9]/g, '');
const m = MOTIFS[motif];
const main = toneValue(tone);
const second = toneValue(detail ?? DETAIL[motif]);
const mirrored = view === 'side';
const object = (
<>
{m.floor && <Floor p={p} cx={m.floor.cx} rx={m.floor.rx} />}
{m.draw(p, mirrored ? undefined : text)}
</>
);
return (
<div
data-clip-ok
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(
'relative isolate aspect-square w-full overflow-hidden [--pi-floor:0.2] dark:[--pi-floor:0.55]',
'[--pi-plinth-a:color-mix(in_oklab,var(--foreground)_3%,var(--surface))] [--pi-plinth-b:var(--background)] [--pi-plinth-c:color-mix(in_oklab,var(--foreground)_9%,var(--surface-2))] [--pi-plinth-top:var(--background)] [--pi-plinth-edge:rgb(255_255_255/0.9)]',
'dark:[--pi-plinth-a:color-mix(in_oklab,var(--foreground)_10%,var(--surface-2))] dark:[--pi-plinth-b:color-mix(in_oklab,var(--foreground)_16%,var(--surface-2))] dark:[--pi-plinth-c:color-mix(in_oklab,var(--foreground)_5%,var(--surface-2))] dark:[--pi-plinth-top:color-mix(in_oklab,var(--foreground)_22%,var(--surface-2))] dark:[--pi-plinth-edge:rgb(255_255_255/0.14)]',
backdrop === 'soft' && 'bg-surface-2',
className,
)}
style={{ '--pi': main, '--pi2': second, ...(backdrop === 'tinted' ? { background: `color-mix(in oklab, ${main} 16%, var(--surface-2))` } : {}) } as React.CSSProperties}
>
{backdrop !== 'plain' && (
<>
<div
aria-hidden
className={cn(
'absolute inset-0 -z-10',
mirrored
? 'bg-[radial-gradient(80%_70%_at_72%_16%,rgb(255_255_255/0.8),transparent_70%)] dark:bg-[radial-gradient(80%_70%_at_72%_16%,rgb(255_255_255/0.11),transparent_70%)]'
: 'bg-[radial-gradient(80%_70%_at_28%_16%,rgb(255_255_255/0.8),transparent_70%)] dark:bg-[radial-gradient(80%_70%_at_28%_16%,rgb(255_255_255/0.11),transparent_70%)]',
)}
/>
<div aria-hidden className="absolute inset-x-0 bottom-0 -z-10 h-2/5 bg-[linear-gradient(to_top,color-mix(in_oklch,var(--foreground)_5%,transparent),transparent)]" />
</>
)}
<svg viewBox={viewBox} preserveAspectRatio="xMidYMid meet" className="absolute inset-0 size-full" aria-hidden>
<Defs p={p} />
{view === 'scene' && (
<g aria-hidden>
{/* A round plinth, lit from the upper left like the product */}
<linearGradient id={p + 'pl'} x1="0" x2="1" y1="0" y2="0">
<stop offset="0" style={{ stopColor: 'var(--pi-plinth-a)' }} />
<stop offset="0.38" style={{ stopColor: 'var(--pi-plinth-b)' }} />
<stop offset="1" style={{ stopColor: 'var(--pi-plinth-c)' }} />
</linearGradient>
<radialGradient id={p + 'ball'} cx="0.36" cy="0.32" r="0.75">
<stop offset="0" style={{ stopColor: light(second, 55) }} />
<stop offset="0.55" style={{ stopColor: second }} />
<stop offset="1" style={{ stopColor: shade(second, 62) }} />
</radialGradient>
<path d="M80 312v120h240V312Z" fill={u(p, 'pl')} />
<ellipse cx="200" cy="312" rx="120" ry="16" style={{ fill: 'var(--pi-plinth-top)' }} />
<path d="M80 312a120 16 0 0 0 240 0" fill="none" style={{ stroke: 'var(--pi-plinth-edge)' }} strokeWidth="1.5" />
<ellipse cx="292" cy="316" rx="20" ry="4" fill={u(p, 'f')} />
<circle cx="292" cy="298" r="16" fill={u(p, 'ball')} />
</g>
)}
{mirrored ? <g transform="translate(400 0) scale(-1 1)">{object}</g> : view === 'scene' ? <g transform="translate(-14 0)">{object}</g> : object}
</svg>
</div>
);
}
/**
* The frame for every product image in the shop blocks: radius, crop and a
* calm surface while an image loads. Children fill it, <img> covers.
*/
export function ProductMedia({ children, className, style }: { children: React.ReactNode; className?: string; style?: React.CSSProperties }) {
return (
<div className={cn('relative overflow-hidden rounded-xl bg-surface-2 *:size-full *:object-cover', className)} style={style}>
{children}
</div>
);
}Häufige Fragen
Wie installiere ich Produktbild?
Mit aiw add @ai-website/product-image. Der Befehl legt die Datei components/ui/product-image.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Produktbild?
4 Varianten, Zwölf Motive, Vier Ansichten als Galerie, Farbwahl auf getöntem Grund und Shop-Teile rund ums Bild.
Kann ich die Texte von Produktbild übersetzen?
Ja. Produktbild bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Menge“, „Menge verringern“ und „Menge erhöhen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Produktbild einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Produktbild mit aiw_components und installieren sie mit aiw_add @ai-website/product-image.