Product image
Drawn product motifs on soft studio light, plus the shop parts every e-commerce component shares: price, rating, quantity, swatches, sizes and tax note.
Variants
Details
What Product image brings along, read from the registry and the source.
- Category
- Products
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.1.0 · since 1.0.0
- File
- components/ui/product-image.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 11 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ProductImageProps interface from components/ui/product-image.tsx, with the comments from the code.
motifrequiredProductMotifmugbowlvasecandlelamppouchbottlebackpacksneakerheadphoneswatchtote
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.
Built-in texts
Product image writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 11 texts
| Key | English | German |
|---|---|---|
| quantity | English Quantity | German Menge |
| decrease | English Decrease quantity | German Menge verringern |
| increase | English Increase quantity | German Menge erhöhen |
| outOf | English out of 5 stars | German von 5 Sternen |
| reviews | English reviews | German Bewertungen |
| salePrice | English Sale price | German Angebotspreis |
| regularPrice | English Regular price | German Regulärer Preis |
| taxIncl | English Incl. VAT, plus shipping | German inkl. MwSt., zzgl. Versand |
| taxInclFree | English Incl. VAT, free shipping | German inkl. MwSt., versandkostenfrei |
| color | English Color | German Farbe |
| soldOut | English sold out | German ausverkauft |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles group, img, label and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,016 lines
'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>
);
}Frequent questions
How do I install Product image?
With aiw add @ai-website/product-image. The command puts components/ui/product-image.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Product image have?
4 variants, Twelve motifs, Four views in a gallery, Color picker on a tinted backdrop and Shop parts around the image.
Can I translate the texts of Product image?
Yes. Product image carries 11 texts of its own in English and German, such as “Quantity”, “Decrease quantity” and “Increase quantity”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Product image?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Product image with aiw_components and install it with aiw_add @ai-website/product-image.