Size guide
Size charts in cm or inches, a fit finder from height and weight, a drawn figure that shows how to measure, a shoe size converter and the guide in a dialog from the size picker.
One of 2 components in Product pages
Variants
Details
What Size guide brings along, read from the registry and the source.
- Category
- Product pages
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/size-guide.tsx
- npm packages
- None
- Languages
- English and German, 27 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SizeGuideProps interface from components/ui/size-guide.tsx, with the comments from the code.
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
Built-in texts
Size guide writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 27 texts
| Key | English | German |
|---|---|---|
| unit | English Unit | German Einheit |
| size | English Size | German Größe |
| measure | English Measure | German Maß |
| height | English Height | German Körpergröße |
| weight | English Weight | German Gewicht |
| fit | English How do you like it to fit? | German Wie soll es sitzen? |
| slim | English Close | German Eng |
| regular | English Regular | German Normal |
| relaxed | English Relaxed | German Locker |
| yourSize | English Your size | German Deine Größe |
| chose | English People with your height and weight chose | German Menschen mit deiner Größe und deinem Gewicht wählten |
| fitsBetween | English You sit between two sizes. For a closer fit take the smaller one. | German Du liegst zwischen zwei Größen. Für einen engeren Sitz nimm die kleinere. |
| useSize | English Choose this size | German Diese Größe wählen |
| chosen | English Size chosen | German Größe gewählt |
| footLength | English Foot length | German Fußlänge |
| footHint | English Stand on a sheet of paper, mark heel and longest toe, measure the distance. | German Auf ein Blatt Papier stellen, Ferse und längsten Zeh markieren, den Abstand messen. |
| shorter | English Shorter | German Kürzer |
| longer | English Longer | German Länger |
| guide | English Size guide | German Größentabelle |
| chart | English Size chart | German Größentabelle |
| howTo | English How to measure | German So misst du richtig |
| close | English Close | German Schließen |
| pickSize | English Pick your size | German Wähle deine Größe |
| addToCart | English Add to cart | German In den Warenkorb |
| step | English Step | German Schritt |
| of | English of | German von |
| tip | English Measure over light clothing, with the tape snug but not tight. | German Über leichter Kleidung messen, das Maßband anliegend, aber nicht stramm. |
Accessibility
Read from the source of the component.
- Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
- Sets aria-checked, aria-controls, aria-expanded, aria-haspopup, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the roles img, radio, radiogroup and region.
- 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
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 6 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, 766 lines
'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} />;
}Frequent questions
How do I install Size guide?
With aiw add @ai-website/size-guide. The command puts components/ui/size-guide.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, tabs, dialog and product-image. It needs no further npm packages.
Which variants does Size guide have?
5 variants, Size chart, Fit finder, How to measure, Shoe size converter and Guide from the size picker. You pick one with the variant prop, for example variant="fit-finder".
Can I translate the texts of Size guide?
Yes. Size guide carries 27 texts of its own in English and German, such as “Unit”, “Size” and “Measure”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Size guide?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Size guide with aiw_components and install it with aiw_add @ai-website/size-guide. Name the variant in your prompt, for example variant="table", and the assistant writes your content straight into the props.
Can I adjust Size guide without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching SizeGuide code to copy.