Skip to content
UI Kit
Accent color

Product configurator

Configure a product, picture and price follow. Step by step, side by side with views, with an engraving or made to measure.

4 variantsOne of 4 components in Product pages

Every choice visible at once

A configurator sells the product the customer has in mind. Picture and price follow every choice, so people see what they put together and what it costs before it goes into the cart.

Step by step offers one choice per step with a segmented bar, the lamp changes color and size with every pick, chips under the picture jump back to a step and the last step lists everything with its price. Side by side keeps a large watch with three views on the left and all options on the right, and a price breakdown opens under the price. Engraving puts the text on the bottle while it is typed, names characters that cannot be engraved and counts to the limit. Made to measure sizes a shelf by width, height and depth, the drawing follows to scale and wide shelves switch to made to order.

Use steps for products with five or more decisions, side by side for fewer options with a strong picture. Show the price change on every option, then upgrades feel fair. Set a default for every choice, then the picture starts complete.

Variants

Step by stepNew in 1.2

One choice per step with a segmented bar. The lamp changes color and size with every pick, chips under the picture jump back to a step and the last step lists everything with its price.

Side by sideNew in 1.2

A large watch with three views on the left that stays in place, all options on the right. Dial and case recolor the picture, the diameter changes its size and a breakdown opens under the price.

EngravingNew in 1.2

Text that stands on the bottle while it is typed. Characters that cannot be engraved are named, the counter turns red past the limit, style and side change the picture in place.

Made to measureNew in 1.2

A shelf by width, height and depth with a slider and a number field each. The drawing follows to scale with measure lines, the price counts along and wide shelves switch to made to order.

Details

What Product configurator brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/product-configurator.tsx
Brings along
utilskitbuttoniconproduct-imageinput
npm packages
None
Languages
English and German, 35 texts of its own
Studio
Adjust in code

Props

The ProductConfiguratorProps interface from components/ui/product-configurator.tsx, with the comments from the code.

  • variantoptional
    ProductConfiguratorVariant

    stepssplitengravingdimensions

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • basePriceoptional
    number

    Price before options

  • motifoptional
    ProductMotif

    Drawn motif for steps, split and engraving

  • optionsoptional
    ConfigOption[]
  • defaultValuesoptional
    Record<string, string | string[]>

    Picked choices on load, option id to choice id(s)

  • engravingoptional
    { maxLength?: number; price?: number; placeholder?: string }

    engraving

  • dimensionsoptional
    { width: [number, number]; height: [number, number]; depth?: number[]; pricePerM2?: number; /** Made to order from this width on, delivery takes longer */ customFrom?: number; }

    dimensions: ranges in cm and the price per square metre of shelf front

  • deliveryoptional
    [string, string?]

    Delivery lines, the second one for made to order

  • renderPreviewoptional
    (values: Record<string, string | string[]>) => React.ReactNode

    Your own picture for a configuration

  • onAddoptional
    (values: Record<string, string | string[] | number>, price: number) => void
  • currencyoptional
    string

Built-in texts

Product configurator writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 35 texts
stepOfEnglish Step {n} of {total}German Schritt {n} von {total}
nextEnglish NextGerman Weiter
backEnglish BackGerman Zurück
reviewEnglish ReviewGerman Übersicht
summaryEnglish Your configurationGerman Deine Konfiguration
editEnglish ChangeGerman Ändern
baseEnglish Base priceGerman Grundpreis
includedEnglish IncludedGerman Inklusive
totalEnglish TotalGerman Gesamt
addEnglish Add to cartGerman In den Warenkorb
addedEnglish In the cartGerman Im Warenkorb
viewEnglish ViewGerman Ansicht
viewsEnglish Front|Side|DetailGerman Vorne|Seite|Detail
priceBreakdownEnglish Price breakdownGerman Preis im Detail
engravingEnglish EngravingGerman Gravur
engravingHintEnglish Letters, numbers, spaces and . & - · up to {n} charactersGerman Buchstaben, Ziffern, Leerzeichen und . & - · bis zu {n} Zeichen
engravingPlaceholderEnglish Your textGerman Dein Text
leftEnglish {n} leftGerman noch {n}
tooLongEnglish {n} characters too many. Shorten the text to fit on one line.German {n} Zeichen zu viel. Kürze den Text, damit er auf eine Zeile passt.
badCharsEnglish We cannot engrave {chars}. Use letters, numbers, space, . & or -.German {chars} können wir nicht gravieren. Nimm Buchstaben, Ziffern, Leerzeichen, . & oder -.
emptyEnglish Type the text for the engraving, or turn engraving off.German Gib den Text für die Gravur ein oder schalte die Gravur aus.
styleEnglish StyleGerman Stil
stylesEnglish Capitals|As typed|SpacedGerman Großbuchstaben|Wie getippt|Gesperrt
placementEnglish PlacementGerman Position
placementsEnglish Front|BackGerman Vorne|Hinten
engraveOnEnglish Add engravingGerman Gravur hinzufügen
widthEnglish WidthGerman Breite
heightEnglish HeightGerman Höhe
depthEnglish DepthGerman Tiefe
materialEnglish MaterialGerman Material
outOfRangeEnglish Choose a value between {min} and {max} cm.German Wähl einen Wert zwischen {min} und {max} cm.
shelvesEnglish {n} shelvesGerman {n} Böden
madeToOrderEnglish Made to orderGerman Auf Bestellung gefertigt
perfectEnglish Fits your configurationGerman Passt zu deiner Konfiguration
cmEnglish cmGerman cm

Accessibility

Read from the source of the component.

  • Product configurator handles the keys Enter and Space itself.
  • Sets aria-checked, aria-describedby, aria-invalid, aria-label, aria-live and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles img, radio, radiogroup and switch.
  • 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, 844 lines
components/ui/product-configurator.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { FlyUp, PRODUCT_TONES, ProductImage, ShopMotion, Swap, TweenPrice, formatPrice, type ProductMotif, type ProductTone } from '@/components/ui/product-image';

/*
 * Configure a product and watch it change.
 *
 *   steps       one choice per step with a stepper, the picture and the price follow every pick
 *   split       a large preview with three views on the left, all options and the price breakdown on the right
 *   engraving   text on the product, checked as you type, shown on the picture in place
 *   dimensions  a shelf by width, height and depth, drawn to scale with measure lines and a price per size
 *
 * Options carry a price difference. The preview uses the drawn product motifs,
 * pass your own pictures through `renderPreview` for real products.
 */

export type ProductConfiguratorVariant = 'steps' | 'split' | 'engraving' | 'dimensions';

export interface ConfigChoice {
  id: string;
  label: string;
  /** Extra price, 0 for included */
  price?: number;
  /** Swatch color, any CSS color or a PRODUCT_TONES key */
  color?: string;
  /** Tone for the drawn preview */
  tone?: ProductTone;
  note?: string;
  soldOut?: boolean;
}

export interface ConfigOption {
  id: string;
  label: string;
  /** swatch: color dots, chips: text boxes, cards: boxes with a note */
  kind?: 'swatch' | 'chips' | 'cards';
  /** What this option changes in the drawn preview */
  drives?: 'tone' | 'detail' | 'zoom' | 'none';
  choices: ConfigChoice[];
  /** Several picks allowed (extras) */
  multiple?: boolean;
}

export interface ProductConfiguratorProps {
  variant?: ProductConfiguratorVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** Price before options */
  basePrice?: number;
  /** Drawn motif for steps, split and engraving */
  motif?: ProductMotif;
  options?: ConfigOption[];
  /** Picked choices on load, option id to choice id(s) */
  defaultValues?: Record<string, string | string[]>;
  /** engraving */
  engraving?: { maxLength?: number; price?: number; placeholder?: string };
  /** dimensions: ranges in cm and the price per square metre of shelf front */
  dimensions?: {
    width: [number, number];
    height: [number, number];
    depth?: number[];
    pricePerM2?: number;
    /** Made to order from this width on, delivery takes longer */
    customFrom?: number;
  };
  /** Delivery lines, the second one for made to order */
  delivery?: [string, string?];
  /** Your own picture for a configuration */
  renderPreview?: (values: Record<string, string | string[]>) => React.ReactNode;
  onAdd?: (values: Record<string, string | string[] | number>, price: number) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    stepOf: 'Step {n} of {total}',
    next: 'Next',
    back: 'Back',
    review: 'Review',
    summary: 'Your configuration',
    edit: 'Change',
    base: 'Base price',
    included: 'Included',
    total: 'Total',
    add: 'Add to cart',
    added: 'In the cart',
    view: 'View',
    views: 'Front|Side|Detail',
    priceBreakdown: 'Price breakdown',
    engraving: 'Engraving',
    engravingHint: 'Letters, numbers, spaces and . & - · up to {n} characters',
    engravingPlaceholder: 'Your text',
    left: '{n} left',
    tooLong: '{n} characters too many. Shorten the text to fit on one line.',
    badChars: 'We cannot engrave {chars}. Use letters, numbers, space, . & or -.',
    empty: 'Type the text for the engraving, or turn engraving off.',
    style: 'Style',
    styles: 'Capitals|As typed|Spaced',
    placement: 'Placement',
    placements: 'Front|Back',
    engraveOn: 'Add engraving',
    width: 'Width',
    height: 'Height',
    depth: 'Depth',
    material: 'Material',
    outOfRange: 'Choose a value between {min} and {max} cm.',
    shelves: '{n} shelves',
    madeToOrder: 'Made to order',
    perfect: 'Fits your configuration',
    cm: 'cm',
  },
  de: {
    stepOf: 'Schritt {n} von {total}',
    next: 'Weiter',
    back: 'Zurück',
    review: 'Übersicht',
    summary: 'Deine Konfiguration',
    edit: 'Ändern',
    base: 'Grundpreis',
    included: 'Inklusive',
    total: 'Gesamt',
    add: 'In den Warenkorb',
    added: 'Im Warenkorb',
    view: 'Ansicht',
    views: 'Vorne|Seite|Detail',
    priceBreakdown: 'Preis im Detail',
    engraving: 'Gravur',
    engravingHint: 'Buchstaben, Ziffern, Leerzeichen und . & - · bis zu {n} Zeichen',
    engravingPlaceholder: 'Dein Text',
    left: 'noch {n}',
    tooLong: '{n} Zeichen zu viel. Kürze den Text, damit er auf eine Zeile passt.',
    badChars: '{chars} können wir nicht gravieren. Nimm Buchstaben, Ziffern, Leerzeichen, . & oder -.',
    empty: 'Gib den Text für die Gravur ein oder schalte die Gravur aus.',
    style: 'Stil',
    styles: 'Großbuchstaben|Wie getippt|Gesperrt',
    placement: 'Position',
    placements: 'Vorne|Hinten',
    engraveOn: 'Gravur hinzufügen',
    width: 'Breite',
    height: 'Höhe',
    depth: 'Tiefe',
    material: 'Material',
    outOfRange: 'Wähl einen Wert zwischen {min} und {max} cm.',
    shelves: '{n} Böden',
    madeToOrder: 'Auf Bestellung gefertigt',
    perfect: 'Passt zu deiner Konfiguration',
    cm: 'cm',
  },
};

type Texts = (typeof TEXTS)['en'];
type Values = Record<string, string | string[]>;

const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const colorOf = (c: ConfigChoice) => c.color ?? (c.tone ? PRODUCT_TONES[c.tone] : undefined);
const asList = (v?: string | string[]) => (Array.isArray(v) ? v : v ? [v] : []);

function startValues(options: ConfigOption[], defaults?: Values): Values {
  const v: Values = {};
  for (const o of options) v[o.id] = defaults?.[o.id] ?? (o.multiple ? [] : (o.choices.find((c) => !c.soldOut)?.id ?? ''));
  return v;
}

function priceOf(base: number, options: ConfigOption[], values: Values) {
  return round(base + options.reduce((a, o) => a + asList(values[o.id]).reduce((b, id) => b + (o.choices.find((c) => c.id === id)?.price ?? 0), 0), 0));
}

/** The drawn product for the current values */
function Preview({ motif = 'lamp', options, values, view = 'front', zoomBase = 1, text }: { motif?: ProductMotif; options: ConfigOption[]; values: Values; view?: 'front' | 'side' | 'close'; zoomBase?: number; text?: string }) {
  let tone: ProductTone | undefined;
  let detail: ProductTone | undefined;
  let zoom = zoomBase;
  for (const o of options) {
    const c = o.choices.find((x) => x.id === values[o.id]);
    if (!c) continue;
    if (o.drives === 'tone') tone = c.tone;
    if (o.drives === 'detail') detail = c.tone;
    if (o.drives === 'zoom') {
      const i = o.choices.indexOf(c);
      zoom = zoomBase * (0.8 + (i / Math.max(1, o.choices.length - 1)) * 0.25);
    }
  }
  return (
    <div key={`${tone}-${detail}-${view}`} className="mx-auto aspect-square h-full max-w-full animate-[kitshop-fade_0.45s_var(--ease-kit)]">
      <ProductImage motif={motif} tone={tone} detail={detail} view={view} zoom={round(zoom, 3)} text={text} backdrop="plain" />
    </div>
  );
}

/** One option as swatches, chips or cards, a radio group or a checkbox list */
function OptionField({ o, values, setValue, t, currency, compact, bare }: { o: ConfigOption; values: Values; setValue: (id: string, v: string | string[]) => void; t: Texts; currency: string; compact?: boolean; bare?: boolean }) {
  const locale = useLocale();
  const name = useId();
  const kind = o.kind ?? 'chips';
  const picked = asList(values[o.id]);
  const current = o.choices.find((c) => c.id === picked[0]);
  const price = (p?: number) => (p ? `+ ${formatPrice(locale, p, currency)}` : t.included);
  const on = (c: ConfigChoice) => (o.multiple ? setValue(o.id, picked.includes(c.id) ? picked.filter((x) => x !== c.id) : [...picked, c.id]) : setValue(o.id, c.id));
  return (
    <fieldset className="min-w-0">
      <legend data-clip-ok={bare || undefined} className={bare ? 'sr-only' : 'mb-3 flex w-full items-baseline justify-between gap-3 text-[14px] font-medium text-foreground'}>
        <span>
          {o.label}
          {kind === 'swatch' && current && <span className="ml-2 font-normal text-muted-foreground">{current.label}</span>}
        </span>
      </legend>
      <div className={cn(kind === 'swatch' ? 'flex flex-wrap gap-1' : kind === 'cards' ? cn('grid grid-cols-1 gap-2.5', !compact && 'sm:grid-cols-2') : 'flex flex-wrap gap-2')}>
        {o.choices.map((c) => {
          const checked = picked.includes(c.id);
          const input = <input type={o.multiple ? 'checkbox' : 'radio'} name={name} checked={checked} disabled={c.soldOut} onChange={() => on(c)} className="peer sr-only" />;
          if (kind === 'swatch')
            return (
              <label key={c.id} title={c.label} className={cn('group/sw relative grid size-11 place-items-center rounded-full', c.soldOut ? 'cursor-not-allowed opacity-40' : 'cursor-pointer')}>
                {input}
                <span className="sr-only">
                  {c.label}
                  {c.price ? `, ${price(c.price)}` : ''}
                </span>
                <span aria-hidden className={cn('absolute inset-0.5 rounded-full border-2 transition-[border-color,transform] duration-300 ease-[var(--ease-kit)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', checked ? 'scale-100 border-foreground' : 'scale-90 border-transparent group-hover/sw:border-border-strong')} />
                <span aria-hidden className="size-8 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12),inset_0_2px_3px_rgb(255_255_255/0.25)]" style={{ background: colorOf(c) }} />
              </label>
            );
          if (kind === 'cards')
            return (
              <label key={c.id} className={cn('flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-[border-color,background-color] duration-300 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', checked ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border-strong bg-card hover:border-foreground/40', c.soldOut && 'cursor-not-allowed opacity-50')}>
                {input}
                <span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center border-2 transition-colors', o.multiple ? 'rounded-[6px]' : 'rounded-full', checked ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
                  {checked && (o.multiple ? <Icon name="check" size={12} strokeWidth={2.8} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" /> : <span className="size-2 animate-[kitshop-pop_0.3s_var(--ease-kit)] rounded-full bg-primary-foreground" />)}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="flex flex-wrap items-baseline justify-between gap-x-2">
                    <span className="text-[14px] font-medium text-foreground">{c.label}</span>
                    <span className="text-[13px] tabular-nums text-muted-foreground">{price(c.price)}</span>
                  </span>
                  {c.note && <span className="mt-0.5 block text-[12px] text-muted-foreground">{c.note}</span>}
                </span>
              </label>
            );
          return (
            <label key={c.id} className={cn('relative flex min-h-11 cursor-pointer flex-col items-center justify-center rounded-lg border px-4 py-1.5 text-center transition-[border-color,background-color,color] duration-200 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', checked ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-card text-foreground hover:border-foreground/50', c.soldOut && 'cursor-not-allowed border-dashed opacity-50')}>
              {input}
              <span className="text-[14px] font-medium">{c.label}</span>
              {c.price ? <span className={cn('text-[11px] tabular-nums', checked ? 'text-background/75' : 'text-muted-foreground')}>+ {formatPrice(locale, c.price, currency)}</span> : null}
            </label>
          );
        })}
      </div>
      {bare && kind === 'swatch' && current && (
        <p key={current.id} className="mt-3 animate-[kitshop-fade_0.3s_var(--ease-kit)] text-[14px] text-muted-foreground">
          {current.label}
          {current.price ? ` · + ${formatPrice(locale, current.price, currency)}` : ''}
        </p>
      )}
    </fieldset>
  );
}

function AddButton({ price, currency, t, onAdd, className }: { price: number; currency: string; t: Texts; onAdd: () => void; className?: string }) {
  const [added, setAdded] = useState(false);
  const [fly, setFly] = useState(0);
  return (
    <span className={cn('relative', className)}>
      <Button size="lg" onClick={() => { onAdd(); setAdded(true); setFly((f) => f + 1); }} className="w-full overflow-hidden">
        <Swap value={added} className="gap-2">
          <Icon name={added ? 'check' : 'bag'} size={17} />
          {added ? t.added : t.add}
          {!added && (
            <>
              <span aria-hidden className="h-4 w-px bg-primary-foreground/35" />
              <TweenPrice value={price} currency={currency} />
            </>
          )}
        </Swap>
      </Button>
      <FlyUp count={fly} className="-top-5" />
    </span>
  );
}

function Head({ props }: { props: ProductConfiguratorProps }) {
  if (!props.title && !props.eyebrow) return null;
  return (
    <div className="mb-8 max-w-[40rem] sm:mb-10">
      {props.eyebrow && <p className="mb-3 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
      {props.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>}
      {props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
    </div>
  );
}

function Stage({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <div data-clip-ok className={cn('relative overflow-hidden rounded-2xl bg-surface-2', className)}>
      <div aria-hidden className="absolute inset-0 bg-[radial-gradient(70%_60%_at_35%_20%,rgb(255_255_255/0.75),transparent_70%)] dark:bg-[radial-gradient(70%_60%_at_35%_20%,rgb(255_255_255/0.08),transparent_70%)]" />
      <div className="relative size-full">{children}</div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* steps                                                               */
/* ------------------------------------------------------------------ */

function Steps(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const options = props.options ?? [];
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [step, setStep] = useState(0);
  const [dir, setDir] = useState<1 | -1>(1);
  const heading = useRef<HTMLHeadingElement>(null);
  const total = options.length + 1;
  const price = priceOf(props.basePrice ?? 0, options, values);
  const go = (i: number) => {
    setDir(i > step ? 1 : -1);
    setStep(i);
    requestAnimationFrame(() => heading.current?.focus());
  };
  const set = (id: string, v: string | string[]) => setValues((x) => ({ ...x, [id]: v }));
  const o = options[step];
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-12">
          <div className="lg:sticky lg:top-8 lg:self-start">
            <Stage className="aspect-[4/3] sm:aspect-[16/10] lg:aspect-square">
              {props.renderPreview ? props.renderPreview(values) : <Preview motif={props.motif} options={options} values={values} />}
            </Stage>
            {/* The picks so far as chips under the picture */}
            <ul className="mt-3 flex flex-wrap gap-1.5" aria-label={t.summary}>
              {options.map((x, i) =>
                asList(values[x.id]).map((id) => {
                  const c = x.choices.find((y) => y.id === id);
                  return c ? (
                    <li key={x.id + id}>
                      <button type="button" onClick={() => go(i)} className="inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-card px-3 text-[12px] text-foreground transition-colors hover:border-border-strong max-sm:h-11">
                        {colorOf(c) && <span aria-hidden className="size-3 rounded-full" style={{ background: colorOf(c) }} />}
                        <span className="text-muted-foreground">{x.label}</span> {c.label}
                      </button>
                    </li>
                  ) : null;
                }),
              )}
            </ul>
          </div>
          <div className="min-w-0">
            <div className="mb-6">
              <p className="text-[13px] font-medium text-muted-foreground">{fill(t.stepOf, { n: step + 1, total })}</p>
              <div className="mt-2 flex gap-1.5" aria-hidden>
                {Array.from({ length: total }, (_, i) => (
                  <span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-track">
                    <span className={cn('block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', i <= step ? 'scale-x-100' : 'scale-x-0')} />
                  </span>
                ))}
              </div>
            </div>
            <div key={step} className={cn('min-h-[14rem]', dir === 1 ? 'animate-[kitshop-next_0.4s_var(--ease-kit)]' : 'animate-[kitshop-prev_0.4s_var(--ease-kit)]')}>
              <h3 ref={heading} tabIndex={-1} className="mb-5 text-[22px] font-semibold tracking-[-0.02em] text-foreground outline-none">
                {o ? o.label : t.summary}
              </h3>
              {o ? (
                <OptionField o={o} values={values} setValue={set} t={t} currency={currency} bare />
              ) : (
                <dl className="divide-y divide-border rounded-xl border border-border bg-card">
                  <div className="flex items-center justify-between gap-3 px-4 py-3 text-[14px]">
                    <dt className="text-muted-foreground">{t.base}</dt>
                    <dd className="tabular-nums text-foreground">{formatPrice(locale, props.basePrice ?? 0, currency)}</dd>
                  </div>
                  {options.map((x, i) => {
                    const cs = asList(values[x.id]).map((id) => x.choices.find((c) => c.id === id)).filter(Boolean) as ConfigChoice[];
                    return (
                      <div key={x.id} className="flex items-center justify-between gap-3 px-4 py-2 text-[14px]">
                        <dt className="min-w-0">
                          <span className="block text-[12px] text-muted-foreground">{x.label}</span>
                          <span className="block truncate text-foreground">{cs.length ? cs.map((c) => c.label).join(', ') : '·'}</span>
                        </dt>
                        <dd className="flex shrink-0 items-center gap-2">
                          <span className="text-[13px] tabular-nums text-muted-foreground">{cs.reduce((a, c) => a + (c.price ?? 0), 0) ? `+ ${formatPrice(locale, cs.reduce((a, c) => a + (c.price ?? 0), 0), currency)}` : t.included}</span>
                          <button type="button" onClick={() => go(i)} aria-label={`${t.edit}, ${x.label}`} className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground max-sm:size-11">
                            <Icon name="edit" size={14} />
                          </button>
                        </dd>
                      </div>
                    );
                  })}
                </dl>
              )}
            </div>
            <div className="mt-8 flex items-center gap-3 border-t border-border pt-6">
              {step > 0 && (
                <Button variant="outline" onClick={() => go(step - 1)} aria-label={t.back} className="w-11 shrink-0 px-0 sm:w-auto sm:px-5">
                  <Icon name="arrow-left" size={17} />
                  <span className="max-sm:sr-only">{t.back}</span>
                </Button>
              )}
              {step < total - 1 ? (
                <>
                  <div className="ml-auto text-right">
                    <p className="text-[11px] text-muted-foreground">{t.total}</p>
                    <TweenPrice value={price} currency={currency} className="text-[18px] font-semibold text-foreground" />
                  </div>
                  <Button onClick={() => go(step + 1)} icon="arrow-right">
                    {step === total - 2 ? t.review : t.next}
                  </Button>
                </>
              ) : (
                <AddButton price={price} currency={currency} t={t} onAdd={() => props.onAdd?.(values, price)} className="flex-1" />
              )}
            </div>
            {props.delivery?.[0] && <p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground"><Icon name="truck" size={15} />{props.delivery[0]}</p>}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* split                                                               */
/* ------------------------------------------------------------------ */

function Split(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const options = props.options ?? [];
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [view, setView] = useState<'front' | 'side' | 'close'>('front');
  const price = priceOf(props.basePrice ?? 0, options, values);
  const set = (id: string, v: string | string[]) => setValues((x) => ({ ...x, [id]: v }));
  const views = t.views.split('|');
  const extras = options.flatMap((o) => asList(values[o.id]).map((id) => ({ o, c: o.choices.find((c) => c.id === id)! })).filter((x) => x.c && x.c.price));
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:gap-14">
          <div className="lg:sticky lg:top-8 lg:self-start">
            <Stage className="aspect-square max-sm:aspect-[5/4]">
              {props.renderPreview ? props.renderPreview(values) : <Preview motif={props.motif} options={options} values={values} view={view} zoomBase={view === 'close' ? 1 : 0.95} />}
              <div role="radiogroup" aria-label={t.view} className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1 rounded-full border border-border bg-background/85 p-1 shadow-[var(--shadow-md)] backdrop-blur">
                {(['front', 'side', 'close'] as const).map((v, i) => (
                  <button key={v} type="button" role="radio" aria-checked={view === v} onClick={() => setView(v)} className={cn('h-9 rounded-full px-3.5 text-[13px] font-medium transition-colors max-sm:h-11', view === v ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}>
                    {views[i]}
                  </button>
                ))}
              </div>
            </Stage>
          </div>
          <div className="min-w-0">
            {props.eyebrow && <p className="mb-2 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
            {props.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>}
            <TweenPrice value={price} currency={currency} className="mt-3 block text-[24px] font-semibold tracking-[-0.02em] text-foreground" />
            {props.description && <p className="mt-3 text-pretty text-[15px] text-muted-foreground">{props.description}</p>}
            <div className="mt-8 space-y-7 border-t border-border pt-7">
              {options.map((o) => (
                <OptionField key={o.id} o={o} values={values} setValue={set} t={t} currency={currency} compact />
              ))}
            </div>
            <details className="group/pb mt-8 rounded-xl border border-border bg-surface">
              <summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 px-4 text-[14px] font-medium text-foreground [&::-webkit-details-marker]:hidden">
                {t.priceBreakdown}
                <Icon name="chevron-down" size={15} className="text-muted-foreground transition-transform group-open/pb:rotate-180 motion-reduce:transition-none" />
              </summary>
              <dl className="space-y-2 border-t border-border px-4 py-3 text-[13px]">
                <div className="flex justify-between gap-3">
                  <dt className="text-muted-foreground">{t.base}</dt>
                  <dd className="tabular-nums text-foreground">{formatPrice(locale, props.basePrice ?? 0, currency)}</dd>
                </div>
                {extras.map(({ o, c }) => (
                  <div key={o.id + c.id} className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] justify-between gap-3">
                    <dt className="text-muted-foreground">
                      {o.label} · {c.label}
                    </dt>
                    <dd className="tabular-nums text-foreground">+ {formatPrice(locale, c.price!, currency)}</dd>
                  </div>
                ))}
                <div className="flex justify-between gap-3 border-t border-border pt-2 font-semibold">
                  <dt className="text-foreground">{t.total}</dt>
                  <dd className="tabular-nums text-foreground">{formatPrice(locale, price, currency)}</dd>
                </div>
              </dl>
            </details>
            <AddButton price={price} currency={currency} t={t} onAdd={() => props.onAdd?.(values, price)} className="mt-6 block" />
            {props.delivery?.[0] && <p className="mt-3 flex items-center justify-center gap-2 text-[13px] text-muted-foreground"><Icon name="truck" size={15} />{props.delivery[0]}</p>}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* engraving                                                           */
/* ------------------------------------------------------------------ */

const ALLOWED = /[A-Za-z0-9ÄÖÜäöüß .&-]/;

function Engraving(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const options = props.options ?? [];
  const max = props.engraving?.maxLength ?? 14;
  const fee = props.engraving?.price ?? 0;
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [on, setOn] = useState(true);
  const [text, setText] = useState(props.engraving?.placeholder ?? '');
  const [style, setStyle] = useState(0);
  const [place, setPlace] = useState(0);
  const [tried, setTried] = useState(false);
  const field = useRef<HTMLDivElement>(null);
  const id = useId();
  const bad = [...new Set([...text].filter((ch) => !ALLOWED.test(ch)))];
  const over = text.length - max;
  const error = !on ? '' : bad.length ? fill(t.badChars, { chars: bad.join(' ') }) : over > 0 ? fill(t.tooLong, { n: over }) : tried && !text.trim() ? t.empty : '';
  useShake(field, !!error);
  const price = round(priceOf(props.basePrice ?? 0, options, values) + (on ? fee : 0));
  const shown = style === 0 ? text.toUpperCase() : text;
  const set = (k: string, v: string | string[]) => setValues((x) => ({ ...x, [k]: v }));
  const styles = t.styles.split('|');
  const places = t.placements.split('|');
  const tone = options.find((o) => o.drives === 'tone');
  const toneChoice = tone?.choices.find((c) => c.id === values[tone.id]);
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
          <div className="md:sticky md:top-8 md:self-start">
            <Stage className="aspect-square max-sm:aspect-[5/4]">
              <div className="relative mx-auto aspect-square h-full max-w-full [container-type:inline-size]">
                <Preview motif={props.motif ?? 'bottle'} options={options} values={values} view={place === 1 ? 'side' : 'front'} />
                {on && shown && !bad.length && (
                  <span
                    aria-hidden
                    key={`${style}-${place}`}
                    className="absolute left-[51.8%] top-[58%] origin-center -translate-x-1/2 -translate-y-1/2 -rotate-90 whitespace-nowrap font-semibold animate-[kitshop-fade_0.4s_var(--ease-kit)]"
                    style={{
                      fontSize: 'clamp(9px, 2.6cqw, 20px)',
                      letterSpacing: style === 2 ? '0.32em' : '0.08em',
                      color: 'color-mix(in oklab, white 78%, transparent)',
                      textShadow: '0 -1px 0 rgb(0 0 0 / 0.35), 0 1px 0 rgb(255 255 255 / 0.25)',
                      left: place === 1 ? '48.2%' : undefined,
                    }}
                  >
                    {shown.slice(0, max)}
                  </span>
                )}
              </div>
            </Stage>
            <p className="mt-3 text-center text-[13px] text-muted-foreground">
              {toneChoice?.label} · {places[place]}
            </p>
          </div>
          <div className="min-w-0 space-y-7">
            {options.map((o) => (
              <OptionField key={o.id} o={o} values={values} setValue={set} t={t} currency={currency} compact />
            ))}
            <div className="border-t border-border pt-7">
              <label className="flex cursor-pointer items-center justify-between gap-4">
                <span>
                  <span className="block text-[15px] font-semibold text-foreground">{t.engraveOn}</span>
                  <span className="block text-[13px] text-muted-foreground">{fee ? `+ ${formatPrice(locale, fee, currency)}` : t.included}</span>
                </span>
                <input type="checkbox" role="switch" checked={on} onChange={(e) => setOn(e.target.checked)} className="peer sr-only" />
                <span aria-hidden className={cn('relative h-7 w-12 shrink-0 rounded-full transition-colors duration-300 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', on ? 'bg-primary' : 'bg-track')}>
                  <span className={cn('absolute left-0.5 top-0.5 size-6 rounded-full bg-white shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', on && 'translate-x-5')} />
                </span>
              </label>
              <div className={cn('grid transition-[grid-template-rows,opacity] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
                <div className="min-h-0 overflow-hidden px-0.5">
                  <div className="space-y-6 pb-1 pt-5">
                    <div ref={field}>
                      <div className="mb-1.5 flex items-baseline justify-between gap-3">
                        <label htmlFor={id} className="text-[13px] font-medium text-foreground">
                          {t.engraving}
                        </label>
                        <span className={cn('text-[12px] tabular-nums', over > 0 ? 'text-destructive' : 'text-muted-foreground')} aria-hidden>
                          {fill(t.left, { n: Math.max(0, max - text.length) })}
                        </span>
                      </div>
                      <input
                        id={id}
                        value={text}
                        onChange={(e) => setText(e.target.value)}
                        onBlur={() => setTried(true)}
                        placeholder={t.engravingPlaceholder}
                        autoComplete="off"
                        aria-invalid={!!error || undefined}
                        aria-describedby={`${id}-h`}
                        className="h-12 w-full rounded-lg border border-input bg-background px-4 text-base tracking-[0.04em] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[15px]"
                      />
                      <p id={`${id}-h`} aria-live="polite" className={cn('mt-1.5 text-[12px]', error ? 'text-destructive' : 'text-muted-foreground')}>
                        {error || fill(t.engravingHint, { n: max })}
                      </p>
                    </div>
                    <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
                      <fieldset>
                        <legend className="mb-2.5 text-[13px] font-medium text-foreground">{t.style}</legend>
                        <div className="flex flex-wrap gap-2">
                          {styles.map((s, i) => (
                            <label key={s} className={cn('flex h-11 cursor-pointer items-center rounded-lg border px-3.5 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', style === i ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                              <input type="radio" name={`${id}-s`} checked={style === i} onChange={() => setStyle(i)} className="sr-only" />
                              {s}
                            </label>
                          ))}
                        </div>
                      </fieldset>
                      <fieldset>
                        <legend className="mb-2.5 text-[13px] font-medium text-foreground">{t.placement}</legend>
                        <div className="flex flex-wrap gap-2">
                          {places.map((s, i) => (
                            <label key={s} className={cn('flex h-11 cursor-pointer items-center rounded-lg border px-3.5 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', place === i ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                              <input type="radio" name={`${id}-p`} checked={place === i} onChange={() => setPlace(i)} className="sr-only" />
                              {s}
                            </label>
                          ))}
                        </div>
                      </fieldset>
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <AddButton
              price={price}
              currency={currency}
              t={t}
              onAdd={() => {
                setTried(true);
                if (on && (!text.trim() || error)) return;
                props.onAdd?.({ ...values, engraving: on ? shown : '' }, price);
              }}
              className="block"
            />
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* dimensions                                                          */
/* ------------------------------------------------------------------ */

function NumberRange({ label, value, min, max, step, onChange, t }: { label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void; t: Texts }) {
  const id = useId();
  const [draft, setDraft] = useState<string | null>(null);
  const shownDraft = draft ?? String(value);
  const n = Number(shownDraft.replace(',', '.'));
  const bad = draft !== null && (!Number.isFinite(n) || n < min || n > max);
  const wrap = useRef<HTMLDivElement>(null);
  useShake(wrap, bad);
  const commit = () => {
    if (draft === null) return;
    if (!bad) {
      onChange(Math.round(n / step) * step);
      setDraft(null);
    }
  };
  return (
    <div ref={wrap}>
      <div className="mb-2 flex items-center justify-between gap-3">
        <label htmlFor={id} className="text-[14px] font-medium text-foreground">
          {label}
        </label>
        <span className="relative">
          <input
            aria-label={`${label}, ${t.cm}`}
            inputMode="numeric"
            value={shownDraft}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={commit}
            onKeyDown={(e) => e.key === 'Enter' && commit()}
            aria-invalid={bad || undefined}
            aria-describedby={bad ? `${id}-e` : undefined}
            className="h-10 w-[5.5rem] rounded-lg border border-input bg-background pl-3 pr-9 text-right text-base tabular-nums text-foreground outline-none transition-[border-color,box-shadow] focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive max-sm:h-11 sm:text-[14px]"
          />
          <span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[13px] text-muted-foreground">{t.cm}</span>
        </span>
      </div>
      <input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => { setDraft(null); onChange(Number(e.target.value)); }} className="h-11 w-full cursor-pointer accent-[var(--primary)]" aria-valuetext={`${value} ${t.cm}`} />
      <div className="flex justify-between text-[11px] tabular-nums text-subtle-foreground" aria-hidden>
        <span>{min}</span>
        <span>{max}</span>
      </div>
      {bad && <p id={`${id}-e`} className="mt-1 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{fill(t.outOfRange, { min, max })}</p>}
    </div>
  );
}

const WOODS: Record<string, { face: string; edge: string }> = {
  oak: { face: 'oklch(0.78 0.06 75)', edge: 'oklch(0.68 0.07 70)' },
  walnut: { face: 'oklch(0.5 0.06 50)', edge: 'oklch(0.42 0.06 45)' },
  white: { face: 'oklch(0.95 0.005 90)', edge: 'oklch(0.86 0.01 90)' },
  black: { face: 'oklch(0.32 0.01 260)', edge: 'oklch(0.25 0.01 260)' },
};

function Shelf({ width, height, depth, wood, t }: { width: number; height: number; depth: number; wood: string; t: Texts }) {
  const c = WOODS[wood] ?? WOODS.oak;
  const wall = 'w' + useId().replace(/[^a-zA-Z0-9]/g, '');
  // Drawn to scale: 1.45 units per cm, room for the measure labels left and on top
  const S = 1.45;
  const w = round(width * S);
  const h = round(height * S);
  const d = round(depth * S * 0.35);
  const x = round(60 + (390 - w - d) / 2);
  const y = round(380 - h);
  const rows = Math.max(2, Math.round(height / 38));
  const cols = Math.max(1, Math.round(width / 60));
  const board = 4;
  return (
    <svg viewBox="0 0 480 440" className="size-full" role="img" aria-label={`${t.width} ${width} ${t.cm}, ${t.height} ${height} ${t.cm}, ${t.depth} ${depth} ${t.cm}`}>
      <defs>
        <pattern id={wall} width="24" height="24" patternUnits="userSpaceOnUse">
          <path d="M24 0H0V24" fill="none" stroke="var(--border)" strokeWidth="0.6" />
        </pattern>
      </defs>
      <rect width="480" height="440" fill={`url(#${wall})`} opacity="0.5" />
      <path d="M0 380H480" stroke="var(--border-strong)" />
      <g>
        <path d={`M${x + w} ${y}l${d} ${-d}v${h}l${-d} ${d}Z`} style={{ fill: c.edge }} />
        <path d={`M${x} ${y}l${d} ${-d}h${w}l${-d} ${d}Z`} style={{ fill: c.face, filter: 'brightness(1.06)' }} />
        <rect x={x} y={y} width={w} height={h} style={{ fill: 'color-mix(in oklab, var(--foreground) 10%, var(--surface-2))' }} />
        {Array.from({ length: rows + 1 }, (_, i) => (
          <rect key={`r${i}`} x={x} y={round(y + (i * (h - board)) / rows)} width={w} height={board} style={{ fill: c.face }} />
        ))}
        {Array.from({ length: cols + 1 }, (_, i) => (
          <rect key={`c${i}`} x={round(x + (i * (w - board)) / cols)} y={y} width={board} height={h} style={{ fill: c.edge }} />
        ))}
      </g>
      {/* Measure lines */}
      <g stroke="var(--muted-foreground)" strokeWidth="1" fill="none">
        <path d={`M${x} ${y - 24}H${x + w}M${x} ${y - 30}v12M${x + w} ${y - 30}v12`} />
        <path d={`M${x - 20} ${y}V${y + h}M${x - 26} ${y}h12M${x - 26} ${y + h}h12`} />
      </g>
      <g fontSize="15" fontWeight="600" textAnchor="middle" style={{ fill: 'var(--foreground)' }}>
        <rect x={round(x + w / 2 - 36)} y={y - 37} width="72" height="26" rx="6" style={{ fill: 'var(--background)' }} />
        <text x={round(x + w / 2)} y={y - 19}>{width} cm</text>
        <rect x={x - 56} y={round(y + h / 2 - 13)} width="72" height="26" rx="6" style={{ fill: 'var(--background)' }} />
        <text x={x - 20} y={round(y + h / 2 + 5)}>{height} cm</text>
      </g>
    </svg>
  );
}

function Dimensions(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const dim = props.dimensions ?? { width: [60, 240], height: [80, 220], depth: [30, 40], pricePerM2: 290, customFrom: 200 };
  const options = props.options ?? [];
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [width, setWidth] = useState(Math.round((dim.width[0] + dim.width[1]) / 2 / 10) * 10);
  const [height, setHeight] = useState(Math.round((dim.height[0] + dim.height[1]) / 2 / 10) * 10);
  const depths = dim.depth ?? [30, 40];
  const [depth, setDepth] = useState(depths[0]);
  const id = useId();
  const area = (width / 100) * (height / 100);
  const base = round(area * (dim.pricePerM2 ?? 290) * (depth > depths[0] ? 1.15 : 1));
  const price = priceOf(base, options, values);
  const custom = dim.customFrom !== undefined && width >= dim.customFrom;
  const material = options.find((o) => o.id === 'material' || o.drives === 'tone');
  const wood = (material && (values[material.id] as string)) || 'oak';
  const rows = Math.max(2, Math.round(height / 38));
  const set = (k: string, v: string | string[]) => setValues((x) => ({ ...x, [k]: v }));
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-14">
          <div className="lg:sticky lg:top-8 lg:self-start">
            <div data-clip-ok className="aspect-[12/11] overflow-hidden rounded-2xl border border-border bg-surface-2">
              <Shelf width={width} height={height} depth={depth} wood={wood} t={t} />
            </div>
            <p className="mt-3 text-center text-[13px] tabular-nums text-muted-foreground">
              {width} × {height} × {depth} {t.cm} · {fill(t.shelves, { n: rows + 1 })}
            </p>
          </div>
          <div className="min-w-0 space-y-7">
            <NumberRange label={t.width} value={width} min={dim.width[0]} max={dim.width[1]} step={10} onChange={setWidth} t={t} />
            <NumberRange label={t.height} value={height} min={dim.height[0]} max={dim.height[1]} step={10} onChange={setHeight} t={t} />
            <fieldset>
              <legend className="mb-3 text-[14px] font-medium text-foreground">{t.depth}</legend>
              <div className="flex gap-2">
                {depths.map((d) => (
                  <label key={d} className={cn('flex h-11 cursor-pointer items-center rounded-lg border px-4 text-[14px] font-medium tabular-nums transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', depth === d ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                    <input type="radio" name={`${id}-d`} checked={depth === d} onChange={() => setDepth(d)} className="sr-only" />
                    {d} {t.cm}
                  </label>
                ))}
              </div>
            </fieldset>
            {options.map((o) => (
              <OptionField key={o.id} o={o} values={values} setValue={set} t={t} currency={currency} compact />
            ))}
            <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)]">
              <div className="flex items-baseline justify-between gap-3">
                <span className="text-[14px] text-muted-foreground">{t.total}</span>
                <TweenPrice value={price} currency={currency} className="text-[24px] font-semibold tracking-[-0.02em] text-foreground" />
              </div>
              <p className="mt-2 flex items-center gap-2 text-[13px]" aria-live="polite">
                <Icon name={custom ? 'hourglass' : 'truck'} size={15} className={custom ? 'text-muted-foreground' : 'text-success'} />
                <span key={String(custom)} className="animate-[kitshop-fade_0.35s_var(--ease-kit)] text-foreground">
                  {custom ? `${t.madeToOrder} · ${props.delivery?.[1] ?? ''}` : props.delivery?.[0]}
                </span>
              </p>
              <AddButton price={price} currency={currency} t={t} onAdd={() => props.onAdd?.({ ...values, width, height, depth }, price)} className="mt-5 block" />
              <p className="mt-2 text-center text-[12px] text-muted-foreground">{formatPrice(locale, round(price / Math.max(0.01, area)), currency)} / m²</p>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/**
 * A product configurator: step by step, side by side with three views,
 * with an engraving shown in place or by dimensions drawn to scale.
 * The price follows every choice.
 */
export function ProductConfigurator(props: ProductConfiguratorProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'steps' } = props;
  if (variant === 'split') return <Split {...props} t={t} currency={currency} />;
  if (variant === 'engraving') return <Engraving {...props} t={t} currency={currency} />;
  if (variant === 'dimensions') return <Dimensions {...props} t={t} currency={currency} />;
  return <Steps {...props} t={t} currency={currency} />;
}

Frequent questions

How do I install Product configurator?

With aiw add @ai-website/product-configurator. The command puts components/ui/product-configurator.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and input. It needs no further npm packages.

Which variants does Product configurator have?

4 variants, Step by step, Side by side, Engraving and Made to measure. You pick one with the variant prop, for example variant="split".

Can I translate the texts of Product configurator?

Yes. Product configurator carries 35 texts of its own in English and German, such as “Next”, “Back” and “Review”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Product configurator?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Product configurator with aiw_components and install it with aiw_add @ai-website/product-configurator. Name the variant in your prompt, for example variant="steps", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard