Größenberatung
Größentabellen in cm oder Zoll, ein Größenfinder aus Körpergröße und Gewicht, eine gezeichnete Figur, die zeigt, wie man misst, ein Schuhgrößen-Umrechner und die Beratung im Dialog direkt an der Größenwahl.
Eine von 2 Components in Product Pages
Variants
Details
Was Größenberatung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Product Pages
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/size-guide.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 27 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SizeGuideProps aus components/ui/size-guide.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSizeGuideVarianttablefit-finderhow-to-measureshoe-converterdialog
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringchartoptionalSizeChartfitSizesoptionalFitSize[]fit-finder
defaultHeightoptionalnumberdefaultWeightoptionalnumberstepsoptionalMeasureStep[]shoesoptionalShoeRow[]defaultFootoptionalnumberproductoptional{ name: string; variant?: string; image?: React.ReactNode }dialog: the product the picker belongs to
defaultOpenoptionalbooleandialog: open on load (previews)
defaultUnitoptional'cm' | 'in'onPickoptional(size: string) => void
Eingebaute Texte
Diese Texte schreibt Größenberatung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 27 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| unit | Englisch Unit | Deutsch Einheit |
| size | Englisch Size | Deutsch Größe |
| measure | Englisch Measure | Deutsch Maß |
| height | Englisch Height | Deutsch Körpergröße |
| weight | Englisch Weight | Deutsch Gewicht |
| fit | Englisch How do you like it to fit? | Deutsch Wie soll es sitzen? |
| slim | Englisch Close | Deutsch Eng |
| regular | Englisch Regular | Deutsch Normal |
| relaxed | Englisch Relaxed | Deutsch Locker |
| yourSize | Englisch Your size | Deutsch Deine Größe |
| chose | Englisch People with your height and weight chose | Deutsch Menschen mit deiner Größe und deinem Gewicht wählten |
| fitsBetween | Englisch You sit between two sizes. For a closer fit take the smaller one. | Deutsch Du liegst zwischen zwei Größen. Für einen engeren Sitz nimm die kleinere. |
| useSize | Englisch Choose this size | Deutsch Diese Größe wählen |
| chosen | Englisch Size chosen | Deutsch Größe gewählt |
| footLength | Englisch Foot length | Deutsch Fußlänge |
| footHint | Englisch Stand on a sheet of paper, mark heel and longest toe, measure the distance. | Deutsch Auf ein Blatt Papier stellen, Ferse und längsten Zeh markieren, den Abstand messen. |
| shorter | Englisch Shorter | Deutsch Kürzer |
| longer | Englisch Longer | Deutsch Länger |
| guide | Englisch Size guide | Deutsch Größentabelle |
| chart | Englisch Size chart | Deutsch Größentabelle |
| howTo | Englisch How to measure | Deutsch So misst du richtig |
| close | Englisch Close | Deutsch Schließen |
| pickSize | Englisch Pick your size | Deutsch Wähle deine Größe |
| addToCart | Englisch Add to cart | Deutsch In den Warenkorb |
| step | Englisch Step | Deutsch Schritt |
| of | Englisch of | Deutsch von |
| tip | Englisch Measure over light clothing, with the tape snug but not tight. | Deutsch Über leichter Kleidung messen, das Maßband anliegend, aber nicht stramm. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
- Setzt aria-checked, aria-controls, aria-expanded, aria-haspopup, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img, radio, radiogroup und region.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 766 Zeilen
'use client';
import { useId, useMemo, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';
import { Tabs } from '@/components/ui/tabs';
import { DialogMotionStyle, scrim, useModalMotion } from '@/components/ui/dialog';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';
/*
* Size guides for fashion and shoe shops.
*
* table the size chart in cm or inches, with EU, US and UK sizes and a crosshair on hover
* fit-finder height, weight and fit in, a recommended size and what similar people chose out
* how-to-measure a drawn figure whose measuring lines light up step by step
* shoe-converter foot length on a slider, the matching shoe sizes in every system
* dialog the size picker of a product page with the guide in a dialog
*
* All measures come in centimetres, inches are calculated.
*/
export type SizeGuideVariant = 'table' | 'fit-finder' | 'how-to-measure' | 'shoe-converter' | 'dialog';
export type MeasureId = 'shoulder' | 'chest' | 'waist' | 'hips' | 'inseam' | 'sleeve';
export interface SizeChart {
/** Column heads, e.g. ["XS", "S", "M", "L", "XL"] */
sizes: string[];
/** One row per measure, values in cm, one per size */
rows: { id?: MeasureId; label: string; values: number[] }[];
/** Size names in other systems, one per size */
systems?: { label: string; values: string[] }[];
}
export interface FitSize {
label: string;
/** Height range in cm */
height: [number, number];
/** Weight range in kg */
weight: [number, number];
}
export interface MeasureStep {
id: MeasureId;
title: string;
text: string;
}
export interface ShoeRow {
/** Foot length in cm */
cm: number;
eu: string;
us: string;
uk: string;
}
export interface SizeGuideProps {
variant?: SizeGuideVariant;
eyebrow?: string;
title?: string;
description?: string;
chart?: SizeChart;
/** fit-finder */
fitSizes?: FitSize[];
defaultHeight?: number;
defaultWeight?: number;
steps?: MeasureStep[];
shoes?: ShoeRow[];
defaultFoot?: number;
/** dialog: the product the picker belongs to */
product?: { name: string; variant?: string; image?: React.ReactNode };
/** dialog: open on load (previews) */
defaultOpen?: boolean;
defaultUnit?: 'cm' | 'in';
onPick?: (size: string) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
unit: 'Unit',
size: 'Size',
measure: 'Measure',
height: 'Height',
weight: 'Weight',
fit: 'How do you like it to fit?',
slim: 'Close',
regular: 'Regular',
relaxed: 'Relaxed',
yourSize: 'Your size',
chose: 'People with your height and weight chose',
fitsBetween: 'You sit between two sizes. For a closer fit take the smaller one.',
useSize: 'Choose this size',
chosen: 'Size chosen',
footLength: 'Foot length',
footHint: 'Stand on a sheet of paper, mark heel and longest toe, measure the distance.',
shorter: 'Shorter',
longer: 'Longer',
guide: 'Size guide',
chart: 'Size chart',
howTo: 'How to measure',
close: 'Close',
pickSize: 'Pick your size',
addToCart: 'Add to cart',
step: 'Step',
of: 'of',
tip: 'Measure over light clothing, with the tape snug but not tight.',
},
de: {
unit: 'Einheit',
size: 'Größe',
measure: 'Maß',
height: 'Körpergröße',
weight: 'Gewicht',
fit: 'Wie soll es sitzen?',
slim: 'Eng',
regular: 'Normal',
relaxed: 'Locker',
yourSize: 'Deine Größe',
chose: 'Menschen mit deiner Größe und deinem Gewicht wählten',
fitsBetween: 'Du liegst zwischen zwei Größen. Für einen engeren Sitz nimm die kleinere.',
useSize: 'Diese Größe wählen',
chosen: 'Größe gewählt',
footLength: 'Fußlänge',
footHint: 'Auf ein Blatt Papier stellen, Ferse und längsten Zeh markieren, den Abstand messen.',
shorter: 'Kürzer',
longer: 'Länger',
guide: 'Größentabelle',
chart: 'Größentabelle',
howTo: 'So misst du richtig',
close: 'Schließen',
pickSize: 'Wähle deine Größe',
addToCart: 'In den Warenkorb',
step: 'Schritt',
of: 'von',
tip: 'Über leichter Kleidung messen, das Maßband anliegend, aber nicht stramm.',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: string; description?: string; aside?: React.ReactNode }) {
if (!title) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-[40rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside}
</div>
);
}
/** cm or inches as a segmented switch with a sliding thumb */
function UnitSwitch({ unit, onChange, t }: { unit: 'cm' | 'in'; onChange: (u: 'cm' | 'in') => void; t: Texts }) {
return (
<div role="radiogroup" aria-label={t.unit} className="relative inline-grid h-11 shrink-0 grid-cols-2 self-start sm:self-auto rounded-lg border border-border-strong bg-surface p-1">
<span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${unit === 'in' ? 100 : 0}%)` }} />
{(['cm', 'in'] as const).map((u) => (
<button key={u} type="button" role="radio" aria-checked={unit === u} onClick={() => onChange(u)} className={cn('relative z-10 w-14 rounded-md after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] text-[13.5px] font-medium transition-colors', unit === u ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', FOCUS)}>
{u}
</button>
))}
</div>
);
}
const asUnit = (locale: 'en' | 'de', cm: number, unit: 'cm' | 'in') => (unit === 'cm' ? formatNumber(locale, cm) : formatNumber(locale, round(cm / 2.54, 1), 1));
/* ------------------------------------------------------------------ */
/* The chart table, used by table and dialog */
/* ------------------------------------------------------------------ */
function ChartTable({ chart, unit, t, active, onActive }: { chart: SizeChart; unit: 'cm' | 'in'; t: Texts; active?: number; onActive?: (i: number | null) => void }) {
const locale = useLocale();
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
const col = hover?.c ?? active ?? -1;
// Phones get their own layout: pick a size, read its measures as a list
const [pick, setPick] = useState(Math.max(0, active ?? Math.floor(chart.sizes.length / 2)));
const name = useId();
const phone = (
<div className="sm:hidden">
<div role="radiogroup" aria-label={t.size} className="grid border-b border-border bg-surface p-1.5" style={{ gridTemplateColumns: `repeat(${chart.sizes.length}, minmax(0, 1fr))` }}>
{chart.sizes.map((sz, c) => (
<label key={sz} className={cn('grid h-11 cursor-pointer place-items-center rounded-lg text-[14px] font-semibold transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', c === pick ? 'bg-foreground text-background' : 'text-muted-foreground')}>
<input type="radio" name={name} checked={c === pick} onChange={() => setPick(c)} className="sr-only" />
{sz}
</label>
))}
</div>
<dl key={`${pick}-${unit}`} className="divide-y divide-border">
{[...(chart.systems ?? []).map((sys) => ({ label: sys.label, value: sys.values[pick], unit: '' })), ...chart.rows.map((row) => ({ label: row.label, value: asUnit(locale, row.values[pick], unit), unit }))].map((x, i) => (
<div key={x.label} className="flex animate-[kitshop-in_0.4s_var(--ease-kit)_both] items-baseline justify-between px-4 py-3 text-[14px]" style={{ animationDelay: `${i * 35}ms` }}>
<dt className="text-muted-foreground">{x.label}</dt>
<dd className="font-semibold tabular-nums text-foreground">
{x.value} <span className="font-normal text-subtle-foreground">{x.unit}</span>
</dd>
</div>
))}
</dl>
</div>
);
return (
<>
{phone}
<div className="hidden sm:block" role="region" aria-label={t.chart}>
<table className="w-full border-separate border-spacing-0 text-[14px]" onPointerLeave={() => { setHover(null); onActive?.(null); }}>
<thead>
<tr>
<th scope="col" className="sticky left-0 z-10 rounded-tl-xl border-b border-border bg-surface px-4 py-3 text-left text-[12.5px] font-medium text-muted-foreground">
{t.size}
</th>
{chart.sizes.map((s, c) => (
<th key={s} scope="col" className={cn('border-b border-border px-3 py-3 text-center text-[14px] font-semibold transition-colors duration-200', c === chart.sizes.length - 1 && 'rounded-tr-xl', c === col ? 'bg-foreground text-background' : 'bg-surface text-foreground')}>
{s}
</th>
))}
</tr>
</thead>
<tbody>
{chart.systems?.map((sys) => (
<tr key={sys.label}>
<th scope="row" className="sticky left-0 z-10 border-b border-border bg-card px-4 py-3 text-left text-[13px] font-medium text-muted-foreground">
{sys.label}
</th>
{sys.values.map((v, c) => (
<td key={c} className={cn('border-b border-border px-3 py-3 text-center tabular-nums text-muted-foreground transition-colors duration-200', c === col && 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-foreground')}>
{v}
</td>
))}
</tr>
))}
{chart.rows.map((row, r) => (
<tr key={row.label}>
<th scope="row" className={cn('sticky left-0 z-10 border-b border-border px-4 py-3 text-left font-medium transition-colors duration-200', hover?.r === r ? 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-foreground' : 'bg-card text-foreground')}>
{row.label} <span className="font-normal text-subtle-foreground">{unit}</span>
</th>
{row.values.map((v, c) => (
<td
key={c}
onPointerEnter={() => { setHover({ r, c }); onActive?.(c); }}
onClick={() => setHover({ r, c })}
className={cn(
'border-b border-border px-3 py-3 text-center tabular-nums transition-colors duration-200',
hover?.r === r && hover?.c === c ? 'bg-primary font-semibold text-primary-foreground' : c === col || hover?.r === r ? 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-foreground' : 'text-muted-foreground',
)}
>
<span key={unit} className="inline-block animate-[kitshop-swap_0.35s_var(--ease-kit)]">
{asUnit(locale, v, unit)}
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
function TableVariant(p: SizeGuideProps & { t: Texts }) {
const { t } = p;
const [unit, setUnit] = useState<'cm' | 'in'>(p.defaultUnit ?? 'cm');
const chart = p.chart ?? { sizes: [], rows: [] };
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} aside={<UnitSwitch unit={unit} onChange={setUnit} t={t} />} />
<Reveal effect="rise" className={cn(CARD, 'overflow-hidden')}>
<ChartTable chart={chart} unit={unit} t={t} />
</Reveal>
<p className="mt-5 flex items-start gap-2 text-[13.5px] text-muted-foreground">
<Icon name="ruler" size={15} className="mt-0.5 shrink-0" />
{t.tip}
</p>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* fit-finder */
/* ------------------------------------------------------------------ */
function RangeField({ label, value, min, max, step, unit, onChange }: { label: string; value: number; min: number; max: number; step: number; unit: string; onChange: (v: number) => void }) {
const id = useId();
const locale = useLocale();
const pct = round(((value - min) / (max - min)) * 100, 2);
return (
<div>
<div className="flex items-baseline justify-between">
<label htmlFor={id} className="text-[14px] font-medium text-foreground">
{label}
</label>
<output htmlFor={id} className="text-[20px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
{formatNumber(locale, value)} <span className="text-[13px] font-normal text-muted-foreground">{unit}</span>
</output>
</div>
<div className="relative mt-3 h-11">
<span aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<span aria-hidden className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
<span aria-hidden className="pointer-events-none absolute top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-[var(--shadow-md)]" style={{ left: `${pct}%` }} />
<input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} className="peer absolute inset-0 size-full cursor-pointer opacity-0" />
</div>
</div>
);
}
function FitFinder(p: SizeGuideProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const sizes = p.fitSizes ?? [];
const [height, setHeight] = useState(p.defaultHeight ?? 176);
const [weight, setWeight] = useState(p.defaultWeight ?? 72);
const [pref, setPref] = useState<-1 | 0 | 1>(0);
const [picked, setPicked] = useState<string | null>(null);
const name = useId();
// A soft score per size: how far height and weight sit from the middle of its range
const scores = useMemo(() => {
const raw = sizes.map((s) => {
const hm = (s.height[0] + s.height[1]) / 2;
const wm = (s.weight[0] + s.weight[1]) / 2;
const hd = (height - hm) / Math.max(1, s.height[1] - s.height[0]);
const wd = (weight - wm) / Math.max(1, s.weight[1] - s.weight[0]);
return Math.exp(-(hd * hd + wd * wd) * 1.4);
});
const sum = raw.reduce((a, b) => a + b, 0) || 1;
return raw.map((v) => v / sum);
}, [sizes, height, weight]);
const best = scores.indexOf(Math.max(...scores));
const rec = Math.max(0, Math.min(sizes.length - 1, best + pref));
const sorted = [...scores].sort((a, b) => b - a);
const between = sorted.length > 1 && sorted[1] > sorted[0] * 0.72;
const [ref, inView] = useInView<HTMLDivElement>();
const recLabel = sizes[rec]?.label ?? '';
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-10">
<Reveal effect="rise" className={cn(CARD, 'space-y-7 p-6 sm:p-8')}>
<RangeField label={t.height} value={height} min={150} max={205} step={1} unit="cm" onChange={(v) => { setHeight(v); setPicked(null); }} />
<RangeField label={t.weight} value={weight} min={45} max={120} step={1} unit="kg" onChange={(v) => { setWeight(v); setPicked(null); }} />
<fieldset>
<legend className="mb-3 text-[14px] font-medium text-foreground">{t.fit}</legend>
<div className="relative grid grid-cols-3 rounded-lg border border-border-strong bg-surface p-1">
<span aria-hidden className="absolute inset-y-1 left-1 w-[calc((100%-0.5rem)/3)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${(pref + 1) * 100}%)` }} />
{([-1, 0, 1] as const).map((v) => (
<label key={v} className={cn('relative z-10 grid min-h-11 cursor-pointer place-items-center rounded-md text-[13.5px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', pref === v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<input type="radio" name={name} checked={pref === v} onChange={() => { setPref(v); setPicked(null); }} className="sr-only" />
{v === -1 ? t.slim : v === 0 ? t.regular : t.relaxed}
</label>
))}
</div>
</fieldset>
</Reveal>
<div ref={ref} className={cn(CARD, 'flex flex-col p-6 sm:p-8')} aria-live="polite">
<p className="text-[14px] text-muted-foreground">{t.yourSize}</p>
<div className="mt-2 flex items-end gap-4">
<Swap value={recLabel}>
<span className="text-[72px] font-semibold leading-[0.9] tracking-[-0.05em] text-foreground">{recLabel}</span>
</Swap>
{between && <p className="mb-1 max-w-[16rem] text-pretty text-[13px] leading-snug text-muted-foreground">{t.fitsBetween}</p>}
</div>
<p className="mt-6 text-[13px] text-muted-foreground">{t.chose}</p>
<ul className="mt-3 space-y-2">
{sizes.map((s, i) => {
const share = scores[i] ?? 0;
return (
<li key={s.label} className="grid grid-cols-[2.5rem_minmax(0,1fr)_3rem] items-center gap-3 text-[13px]">
<span className={cn('font-semibold tabular-nums', i === rec ? 'text-foreground' : 'text-muted-foreground')}>{s.label}</span>
<span className="relative h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]" aria-hidden>
<span
className={cn('absolute inset-0 origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', i === rec ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_35%,transparent)]')}
style={{ transform: `scaleX(${inView ? round(share, 3) : 0})`, transitionDelay: `${i * 50}ms` }}
/>
</span>
<span className="text-right tabular-nums text-muted-foreground">{locale === 'de' ? `${Math.round(share * 100)} %` : `${Math.round(share * 100)}%`}</span>
</li>
);
})}
</ul>
<span aria-hidden className="block min-h-7 flex-1" />
<Button
className="w-full"
size="lg"
iconLeft={picked === recLabel ? 'check-circle' : 'check'}
onClick={() => {
setPicked(recLabel);
p.onPick?.(recLabel);
}}
>
<Swap value={picked === recLabel}>{picked === recLabel ? `${t.chosen} · ${recLabel}` : `${t.useSize} · ${recLabel}`}</Swap>
</Button>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The figure for how-to-measure and the dialog */
/* ------------------------------------------------------------------ */
const LINES: Record<MeasureId, { d: string; label: [number, number] }> = {
shoulder: { d: 'M62 84 C80 78 120 78 138 84', label: [100, 79] },
chest: { d: 'M55 112 H145', label: [100, 112] },
waist: { d: 'M61 166 H139', label: [100, 166] },
hips: { d: 'M60 210 H140', label: [100, 210] },
inseam: { d: 'M100 236 V384', label: [100, 306] },
sleeve: { d: 'M140 86 C152 96 158 140 160 186 L163 234', label: [158.5, 160] },
};
function Figure({ active, steps, onPick }: { active: MeasureId; steps: MeasureStep[]; onPick?: (id: MeasureId) => void }) {
const [ref, inView] = useInView<HTMLDivElement>();
const ids = steps.map((s) => s.id);
return (
<div ref={ref} className="relative mx-auto w-full max-w-[12rem] sm:max-w-[17rem]">
<svg viewBox="20 8 180 392" className="h-auto w-full" role="img" aria-hidden>
<g className="fill-[color-mix(in_oklab,var(--foreground)_9%,var(--card))] stroke-[color-mix(in_oklab,var(--foreground)_22%,var(--card))]" strokeWidth="1.2">
<circle cx="100" cy="36" r="19" />
<rect x="92" y="54" width="16" height="22" rx="6" />
<path d="M54 92 C44 100 40 140 38 180 L36 236 L46 236 L52 182 C54 150 56 120 58 110 Z" />
<path d="M146 92 C156 100 160 140 162 180 L164 236 L154 236 L148 182 C146 150 144 120 142 110 Z" />
<path d="M62 84 C80 76 120 76 138 84 C146 88 148 96 146 110 L140 160 C138 175 134 185 136 200 L140 228 L60 228 L64 200 C66 185 62 175 60 160 L54 110 C52 96 54 88 62 84 Z" />
<path d="M60 228 L98 228 L95 388 L76 388 Z" />
<path d="M102 228 L140 228 L124 388 L105 388 Z" />
</g>
{ids.map((id, i) => {
const on = id === active;
const line = LINES[id];
return (
<g key={id} className={cn('transition-opacity duration-500', on ? 'opacity-100' : 'opacity-35')}>
<path
d={line.d}
fill="none"
strokeWidth={on ? 2.4 : 1.6}
strokeLinecap="round"
pathLength={1}
strokeDasharray="1"
strokeDashoffset={inView ? 0 : 1}
className={cn('transition-[stroke-dashoffset,stroke-width] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'stroke-primary' : 'stroke-foreground')}
style={{ transitionDelay: inView ? `${i * 140}ms` : '0ms' }}
/>
<circle cx={line.label[0]} cy={line.label[1]} r="9" className={cn('transition-colors duration-300', on ? 'fill-primary' : 'fill-card stroke-border-strong')} strokeWidth="1" />
<text x={line.label[0]} y={line.label[1] + 3.6} textAnchor="middle" className={cn('text-[10px] font-semibold', on ? 'fill-primary-foreground' : 'fill-foreground')}>
{i + 1}
</text>
</g>
);
})}
</svg>
{onPick && (
<div className="absolute inset-0">
{ids.map((id, i) => {
const [x, y] = LINES[id].label;
return (
<button
key={id}
type="button"
tabIndex={-1}
aria-hidden
onClick={() => onPick(id)}
className="absolute size-11 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ left: `${round(((x - 20) / 180) * 100, 2)}%`, top: `${round(((y - 8) / 392) * 100, 2)}%` }}
title={String(i + 1)}
/>
);
})}
</div>
)}
</div>
);
}
function MeasureSteps({ steps, active, onPick, t }: { steps: MeasureStep[]; active: MeasureId; onPick: (id: MeasureId) => void; t: Texts }) {
return (
<ol className="space-y-2">
{steps.map((s, i) => {
const on = s.id === active;
return (
<li key={s.id}>
<button
type="button"
aria-expanded={on}
onClick={() => onPick(s.id)}
onPointerEnter={(e) => e.pointerType === 'mouse' && onPick(s.id)}
className={cn('flex w-full gap-4 rounded-xl border p-4 text-left transition-[background-color,border-color] duration-300', on ? 'border-border-strong bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'border-transparent hover:bg-surface', FOCUS)}
>
<span className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[12.5px] font-semibold tabular-nums transition-colors duration-300', on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold text-foreground">{s.title}</span>
<span className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<span className="overflow-hidden">
<span className="block pt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</span>
</span>
</span>
</span>
</button>
</li>
);
})}
</ol>
);
}
function HowToMeasure(p: SizeGuideProps & { t: Texts }) {
const { t } = p;
const steps = p.steps ?? [];
const [active, setActive] = useState<MeasureId>(steps[0]?.id ?? 'chest');
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)] md:items-start lg:gap-16">
<Reveal effect="scale" className={cn(CARD, 'bg-[radial-gradient(70%_60%_at_50%_30%,color-mix(in_oklab,var(--primary)_7%,transparent),transparent)] px-6 py-8 md:sticky md:top-8')}>
<Figure active={active} steps={steps} onPick={setActive} />
</Reveal>
<div className="md:self-center">
<MeasureSteps steps={steps} active={active} onPick={setActive} t={t} />
<p className="mt-6 flex items-start gap-2 ps-4 text-[13.5px] text-muted-foreground">
<Icon name="lightbulb" size={15} className="mt-0.5 shrink-0" />
{t.tip}
</p>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* shoe-converter */
/* ------------------------------------------------------------------ */
function ShoeConverter(p: SizeGuideProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const rows = p.shoes ?? [];
const min = rows[0]?.cm ?? 22;
const max = rows[rows.length - 1]?.cm ?? 30;
const [foot, setFoot] = useState(p.defaultFoot ?? 26.5);
// The row whose foot length is the first one that fits the foot
const idx = Math.max(0, rows.findIndex((r) => r.cm >= foot - 0.001));
const row = rows[idx];
const id = useId();
const pct = round(((foot - min) / Math.max(0.1, max - min)) * 100, 2);
const scale = round(0.82 + ((foot - min) / Math.max(0.1, max - min)) * 0.18, 3);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start lg:gap-10">
<Reveal effect="rise" className={cn(CARD, 'grid grid-cols-[6.5rem_minmax(0,1fr)] gap-6 p-6 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-8 sm:p-8')}>
{/* A footprint with a measuring line from heel to toe, both grow with the length */}
<div className="relative grid h-[15rem] place-items-end justify-center self-center sm:h-[17rem]" aria-hidden>
<svg viewBox="-4 0 96 176" className="h-full w-auto overflow-visible">
<g className="origin-bottom transition-transform duration-500 ease-[var(--ease-kit)] [transform-box:fill-box] motion-reduce:transition-none" style={{ transform: `scale(${scale})` }}>
<g className="fill-[color-mix(in_oklab,var(--primary)_16%,var(--card))] stroke-[color-mix(in_oklab,var(--primary)_62%,var(--foreground))]" strokeWidth="1.4">
<path d="M35 30 C50 30 58 40 60 56 C62 76 63 98 62 118 C60 140 56 154 48 162 C43 167 38 168 33 168 C25 168 19 163 16 153 C12 138 14 120 16 104 C18 86 12 70 11 54 C10 38 20 30 35 30 Z" />
<ellipse cx="23" cy="17" rx="7" ry="9" />
<ellipse cx="36" cy="12" rx="5" ry="6.5" />
<ellipse cx="46.5" cy="14" rx="4.5" ry="5.5" />
<ellipse cx="55" cy="20" rx="4" ry="5" />
<ellipse cx="61.5" cy="28.5" rx="3.4" ry="4.4" />
</g>
<g className="stroke-foreground" strokeWidth="1.2" fill="none" strokeLinecap="round">
<path d="M80 9 V168" />
<path d="M76 13 L80 9 L84 13 M76 164 L80 168 L84 164 M74 9 H86 M74 168 H86" />
</g>
</g>
</svg>
</div>
<div className="flex flex-col">
<label htmlFor={id} className="text-[14px] font-medium text-foreground">
{t.footLength}
</label>
<output htmlFor={id} className="mt-1 text-[44px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground">
{formatNumber(locale, foot, 1)} <span className="text-[18px] font-normal tracking-normal text-muted-foreground">cm</span>
</output>
<div className="relative mt-6 h-11">
<span aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<span aria-hidden className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
<span aria-hidden className="pointer-events-none absolute top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-[var(--shadow-md)]" style={{ left: `${pct}%` }} />
<input id={id} type="range" min={min} max={max} step={0.5} value={foot} onChange={(e) => setFoot(Number(e.target.value))} className="absolute inset-0 size-full cursor-pointer opacity-0" />
</div>
<div className="mt-2 flex justify-between text-[12px] text-muted-foreground" aria-hidden>
<span>{t.shorter}</span>
<span>{t.longer}</span>
</div>
<p className="mt-6 text-pretty text-[13px] leading-relaxed text-muted-foreground">{t.footHint}</p>
</div>
</Reveal>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-2xl border border-border bg-border" aria-live="polite">
{(['eu', 'us', 'uk'] as const).map((k) => (
<div key={k} className="bg-card p-5 text-center">
<p className="text-[12.5px] font-medium uppercase tracking-[0.06em] text-muted-foreground">{k}</p>
<Swap value={row?.[k] ?? ''}>
<span className="mt-1 block text-[34px] font-semibold tracking-[-0.03em] tabular-nums text-foreground">{row?.[k]}</span>
</Swap>
</div>
))}
</div>
<div className={cn(CARD, 'overflow-hidden')}>
<table className="w-full text-center text-[13.5px]">
<thead className="bg-surface text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="py-2.5 font-medium">cm</th>
<th scope="col" className="py-2.5 font-medium">EU</th>
<th scope="col" className="py-2.5 font-medium">US</th>
<th scope="col" className="py-2.5 font-medium">UK</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.cm} className={cn('border-t border-border transition-colors duration-300', i === idx ? 'bg-foreground text-background' : 'text-muted-foreground')}>
<td className="py-2 tabular-nums">{formatNumber(locale, r.cm, 1)}</td>
<td className="py-2 tabular-nums">{r.eu}</td>
<td className="py-2 tabular-nums">{r.us}</td>
<td className="py-2 tabular-nums">{r.uk}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* dialog */
/* ------------------------------------------------------------------ */
function DialogVariant(p: SizeGuideProps & { t: Texts }) {
const { t } = p;
const chart = p.chart ?? { sizes: [], rows: [] };
const steps = p.steps ?? [];
const [open, setOpen] = useState(!!p.defaultOpen);
const [modal, present] = useModalMotion(open, setOpen, { motion: 'bottom' });
const [unit, setUnit] = useState<'cm' | 'in'>(p.defaultUnit ?? 'cm');
const [size, setSize] = useState(chart.sizes[Math.floor(chart.sizes.length / 2)] ?? '');
const [active, setActive] = useState<MeasureId>(steps[0]?.id ?? 'chest');
const id = useId();
const sizeIndex = chart.sizes.indexOf(size);
return (
<Section className={p.className}>
<ShopMotion />
<DialogMotionStyle />
<div className={cn('mx-auto grid grid-cols-1 items-center gap-8', p.product?.image ? 'max-w-[60rem] md:grid-cols-2 md:gap-12' : 'max-w-[30rem]')}>
{p.product?.image && (
<Reveal effect="scale" className="overflow-hidden rounded-2xl bg-surface-2 [&>*]:aspect-[4/3] [&>*]:size-full md:[&>*]:aspect-square">
{p.product.image}
</Reveal>
)}
<div className={cn(CARD, 'p-6 sm:p-7')}>
{p.eyebrow && <Eyebrow className="mb-2">{p.eyebrow}</Eyebrow>}
<h2 className="text-[22px] font-semibold tracking-[-0.02em] text-foreground">{p.product?.name ?? p.title}</h2>
{p.product?.variant && <p className="mt-1 text-[14px] text-muted-foreground">{p.product.variant}</p>}
<fieldset className="mt-6">
<legend className="mb-3 flex w-full items-baseline justify-between text-[14px] font-medium text-foreground">
{t.pickSize}
<button type="button" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-controls={id} className={cn('inline-flex min-h-11 items-center gap-1.5 rounded-md text-[13.5px] font-medium text-foreground underline decoration-border-strong underline-offset-4 hover:decoration-foreground', FOCUS)}>
<Icon name="ruler" size={15} />
{t.guide}
</button>
</legend>
<div role="radiogroup" aria-label={t.size} className="grid grid-cols-5 gap-2">
{chart.sizes.map((s) => (
<label key={s} className={cn('grid h-11 cursor-pointer place-items-center rounded-lg border text-[14px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', s === size ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-background text-foreground hover:bg-accent')}>
<input type="radio" name={`${id}-size`} checked={s === size} onChange={() => setSize(s)} className="sr-only" />
{s}
</label>
))}
</div>
</fieldset>
<Button size="lg" className="mt-6 w-full" iconLeft="bag">
{t.addToCart}
</Button>
</div>
</div>
<dialog
{...modal}
id={id}
aria-labelledby={`${id}-t`}
className={cn('kitfx-dialog m-auto w-full max-w-none overflow-visible bg-transparent p-3 text-left outline-none data-[closing]:pointer-events-none max-sm:mb-0 max-sm:p-0 sm:w-[calc(100%-2rem)] sm:max-w-[48rem]', scrim)}
>
{present && (
<div className="relative flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden rounded-2xl border border-border bg-popover shadow-[var(--highlight),var(--shadow-lg)] max-sm:max-h-[88dvh] max-sm:rounded-b-none">
<div className="kitfx-dialog-part flex items-start justify-between gap-4 border-b border-border px-6 py-4">
<div>
<h2 id={`${id}-t`} className="text-[17px] font-semibold text-foreground">
{t.guide}
</h2>
{p.product && <p className="text-[13px] text-muted-foreground">{p.product.name}</p>}
</div>
<div className="flex items-center gap-2">
<UnitSwitch unit={unit} onChange={setUnit} t={t} />
<button type="button" onClick={() => setOpen(false)} aria-label={t.close} className={cn('group/close grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}>
<Icon name="x" size={18} className="transition-transform duration-300 group-hover/close:rotate-90" />
</button>
</div>
</div>
<div className="kitfx-dialog-part min-h-0 flex-1 overflow-y-auto px-6 py-5">
<Tabs
label={t.guide}
variant="underline"
items={[
{ value: 'chart', label: t.chart, content: <div className="overflow-hidden rounded-xl border border-border"><ChartTable chart={chart} unit={unit} t={t} active={sizeIndex} /></div> },
{
value: 'measure',
label: t.howTo,
content: (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-[12rem_minmax(0,1fr)] sm:items-start">
<div className="mx-auto w-40 sm:w-full">
<Figure active={active} steps={steps} onPick={setActive} />
</div>
<MeasureSteps steps={steps} active={active} onPick={setActive} t={t} />
</div>
),
},
]}
panelClassName="pt-4"
/>
</div>
</div>
)}
</dialog>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Size guides in five layouts. table: the chart in cm or inches. fit-finder:
* a recommended size. how-to-measure: a figure with measuring lines.
* shoe-converter: foot length to shoe sizes. dialog: the guide from the size picker.
*/
export function SizeGuide(props: SizeGuideProps) {
const { variant = 'table' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'fit-finder') return <FitFinder {...props} t={t} />;
if (variant === 'how-to-measure') return <HowToMeasure {...props} t={t} />;
if (variant === 'shoe-converter') return <ShoeConverter {...props} t={t} />;
if (variant === 'dialog') return <DialogVariant {...props} t={t} />;
return <TableVariant {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Größenberatung?
Mit aiw add @ai-website/size-guide. Der Befehl legt die Datei components/ui/size-guide.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, tabs, dialog und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Größenberatung?
5 Varianten, Größentabelle, Größenfinder, Richtig messen, Schuhgrößen-Umrechner und Beratung an der Größenwahl. Du wählst sie mit der Prop variant, zum Beispiel variant="fit-finder".
Kann ich die Texte von Größenberatung übersetzen?
Ja. Größenberatung bringt 27 eigene Texte auf Deutsch und Englisch mit, etwa „Einheit“, „Größe“ und „Maß“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Größenberatung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Größenberatung mit aiw_components und installieren sie mit aiw_add @ai-website/size-guide. Nenn im Prompt die Variante, etwa variant="table", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Größenberatung ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SizeGuide-Code zum Kopieren.