Size finder
A size advisor that names one size and how sure it is. By height and build, a piece you own, foot length or at the size row.
One size, and how sure it is
The right size is the surest way to an order that stays. This size advisor names one size and how confident the recommendation is, so customers order once and keep it.
Four questions takes height and weight on a large slider in metric or imperial units, with drawn builds and fits to pick, and shows the result with a confidence ring and a scale of all sizes. Compared with yours starts from a piece the customer owns and draws the recommended size over its dashed outline, with a line that says if the cut runs larger. Shoe size shows how to measure, marks the length on the paper and gives the result in three systems. At the size row adds a link to the size row of a product page, a small panel asks height and fit and picks the recommended size.
Put the link right at the size row, that is where the doubt comes up. Say when a cut runs larger or smaller. Show the confidence openly, a clear between two sizes reads as honest advice.
Variants
Details
What Fit finder brings along, read from the registry and the source.
- Category
- Product pages
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/fit-finder.tsx
- Brings along
- utilskitbuttoniconproduct-imageinput
- npm packages
- None
- Languages
- English and German, 57 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FitFinderProps interface from components/ui/fit-finder.tsx, with the comments from the code.
variantoptionalFitFinderVariantstepsreferenceshoesinline
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringchartoptionalFitRow[]steps, reference, inline: the size chart, smallest first
shoesoptionalShoeRow[]shoes: length chart, smallest first
referencesoptional{ id: string; label: string; offset?: number }[]reference: pieces to compare with and how our cut differs, in sizes (+1 runs large)
soldOutoptionalstring[]inline: sizes that are sold out
imperialoptionalbooleanImperial units on load (ft, in, lb). Default in English.
recommendoptional(a: { height: number; weight: number; build: number; fit: number }) => { size: string; confidence: number }Your own recommendation
onPickoptional(size: string) => void
Built-in texts
Fit finder writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 57 texts
| Key | English | German |
|---|---|---|
| stepOf | English Question {n} of {total} | German Frage {n} von {total} |
| height | English How tall are you? | German Wie groß bist du? |
| weight | English What do you weigh? | German Wie viel wiegst du? |
| build | English Which build is closest to yours? | German Welche Statur kommt deiner am nächsten? |
| fit | English How do you like it to sit? | German Wie soll es sitzen? |
| builds | English Narrow|Average|Broad | German Schmal|Mittel|Kräftig |
| buildNotes | English Slim shoulders and waist|Shoulders and waist in balance|Wide chest or shoulders | German Schmale Schultern und Taille|Schultern und Taille im Gleichgewicht|Breite Brust oder Schultern |
| fits | English Close|Regular|Relaxed | German Eng|Normal|Locker |
| fitNotes | English Follows the body|Room to move|Loose and easy | German Folgt dem Körper|Raum zum Bewegen|Weit und leicht |
| next | English Next | German Weiter |
| back | English Back | German Zurück |
| show | English Show my size | German Meine Größe zeigen |
| again | English Start over | German Neu beginnen |
| yourSize | English Your size | German Deine Größe |
| sure | English {pct} sure | German {pct} sicher |
| sureLabel | English How sure we are | German Wie sicher wir sind |
| between | English You are between {a} and {b}. For layers underneath, take {b}. | German Du liegst zwischen {a} und {b}. Für Lagen darunter nimm {b}. |
| centre | English You sit right in the middle of this size. | German Du liegst genau in der Mitte dieser Größe. |
| take | English Choose size {size} | German Größe {size} wählen |
| taken | English Size {size} chosen | German Größe {size} gewählt |
| basis | English Based on {n} returns and reviews of this cut | German Gerechnet aus {n} Rücksendungen und Bewertungen dieses Schnitts |
| units | English Units | German Einheiten |
| metric | English cm, kg | German cm, kg |
| imperialU | English ft, lb | German ft, lb |
| refTitle | English A piece you own that fits well | German Ein Teil, das dir gut passt |
| refType | English What kind of piece? | German Was für ein Teil? |
| refSize | English Which size is it? | German Welche Größe hat es? |
| refFeel | English How does it fit you? | German Wie sitzt es bei dir? |
| feels | English A bit tight|Just right|A bit loose | German Etwas eng|Genau richtig|Etwas weit |
| refResult | English In this cut, take {size} | German In diesem Schnitt nimm {size} |
| refRuns | English Our cut runs {dir} than most. The outlines show both. | German Unser Schnitt fällt {dir} aus als die meisten. Die Umrisse zeigen beide. |
| larger | English larger | German größer |
| smaller | English smaller | German kleiner |
| same | English Our cut matches yours. | German Unser Schnitt entspricht deinem. |
| yours | English Your piece | German Dein Teil |
| ours | English Ours in {size} | German Unseres in {size} |
| refMissing | English Choose the kind of piece, its size and how it fits. | German Wähl die Art des Teils, seine Größe und wie es sitzt. |
| footLength | English Foot length | German Fußlänge |
| footHint | English Stand on paper, mark heel and longest toe, measure the line. Between {min} and {max} mm. | German Stell dich auf Papier, markier Ferse und längsten Zeh, miss die Linie. Zwischen {min} und {max} mm. |
| footError | English Enter a length between {min} and {max} mm, measured from heel to the longest toe. | German Gib eine Länge zwischen {min} und {max} mm ein, gemessen von der Ferse bis zum längsten Zeh. |
| footEmpty | English Enter your foot length in millimetres. | German Gib deine Fußlänge in Millimetern ein. |
| width | English Foot width | German Fußbreite |
| widths | English Narrow|Regular|Wide | German Schmal|Normal|Breit |
| socks | English Socks you wear | German Deine Socken |
| sockTypes | English Thin|Thick | German Dünn|Dick |
| findShoe | English Find my size | German Meine Größe finden |
| shoeResult | English Your size | German Deine Größe |
| halfUp | English For wide feet and thick socks we went half a size up. | German Breiter Fuß und dicke Socken, wir sind eine halbe Größe hoch gegangen. |
| shoeNote | English A thumb of room in front of the toes, that is how it should be. | German Ein Daumen Platz vor den Zehen, so soll es sein. |
| sizeLabel | English Size | German Größe |
| sizeGuide | English Find my size | German Größe finden |
| close | English Close | German Schließen |
| quick | English Two questions, one size | German Zwei Fragen, eine Größe |
| recommended | English Recommended | German Empfohlen |
| soldOutL | English sold out | German ausverkauft |
| cmUnit | English cm | German cm |
| mm | English mm | German mm |
Accessibility
Read from the source of the component.
- Fit finder handles the keys Escape itself.
- Sets aria-checked, aria-controls, aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles alert, dialog, img, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 821 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { ShopMotion } from '@/components/ui/product-image';
/*
* A size advisor in steps. It asks, then names one size and how sure it is.
*
* steps height, weight, build and fit, one question per screen, then the size with a confidence ring
* reference a piece you own that fits: type, size and how it sits, compared as two outlines
* shoes foot length with a drawn guide, width and socks, the size in three systems
* inline the size row of a product page, a small panel asks two questions and marks your size
*
* The math is simple on purpose: a chart of ranges and a nudge for build and fit.
* Pass your own chart, or your own `recommend` function.
*/
export type FitFinderVariant = 'steps' | 'reference' | 'shoes' | 'inline';
export interface FitRow {
size: string;
/** Body height in cm */
height: [number, number];
/** Body weight in kg */
weight: [number, number];
}
export interface ShoeRow {
/** Foot length in mm, up to this value */
footMax: number;
eu: string;
us: string;
uk: string;
}
export interface FitFinderProps {
variant?: FitFinderVariant;
eyebrow?: string;
title?: string;
description?: string;
/** steps, reference, inline: the size chart, smallest first */
chart?: FitRow[];
/** shoes: length chart, smallest first */
shoes?: ShoeRow[];
/** reference: pieces to compare with and how our cut differs, in sizes (+1 runs large) */
references?: { id: string; label: string; offset?: number }[];
/** inline: sizes that are sold out */
soldOut?: string[];
/** Imperial units on load (ft, in, lb). Default in English. */
imperial?: boolean;
/** Your own recommendation */
recommend?: (a: { height: number; weight: number; build: number; fit: number }) => { size: string; confidence: number };
onPick?: (size: string) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
stepOf: 'Question {n} of {total}',
height: 'How tall are you?',
weight: 'What do you weigh?',
build: 'Which build is closest to yours?',
fit: 'How do you like it to sit?',
builds: 'Narrow|Average|Broad',
buildNotes: 'Slim shoulders and waist|Shoulders and waist in balance|Wide chest or shoulders',
fits: 'Close|Regular|Relaxed',
fitNotes: 'Follows the body|Room to move|Loose and easy',
next: 'Next',
back: 'Back',
show: 'Show my size',
again: 'Start over',
yourSize: 'Your size',
sure: '{pct} sure',
sureLabel: 'How sure we are',
between: 'You are between {a} and {b}. For layers underneath, take {b}.',
centre: 'You sit right in the middle of this size.',
take: 'Choose size {size}',
taken: 'Size {size} chosen',
basis: 'Based on {n} returns and reviews of this cut',
units: 'Units',
metric: 'cm, kg',
imperialU: 'ft, lb',
refTitle: 'A piece you own that fits well',
refType: 'What kind of piece?',
refSize: 'Which size is it?',
refFeel: 'How does it fit you?',
feels: 'A bit tight|Just right|A bit loose',
refResult: 'In this cut, take {size}',
refRuns: 'Our cut runs {dir} than most. The outlines show both.',
larger: 'larger',
smaller: 'smaller',
same: 'Our cut matches yours.',
yours: 'Your piece',
ours: 'Ours in {size}',
refMissing: 'Choose the kind of piece, its size and how it fits.',
footLength: 'Foot length',
footHint: 'Stand on paper, mark heel and longest toe, measure the line. Between {min} and {max} mm.',
footError: 'Enter a length between {min} and {max} mm, measured from heel to the longest toe.',
footEmpty: 'Enter your foot length in millimetres.',
width: 'Foot width',
widths: 'Narrow|Regular|Wide',
socks: 'Socks you wear',
sockTypes: 'Thin|Thick',
findShoe: 'Find my size',
shoeResult: 'Your size',
halfUp: 'For wide feet and thick socks we went half a size up.',
shoeNote: 'A thumb of room in front of the toes, that is how it should be.',
sizeLabel: 'Size',
sizeGuide: 'Find my size',
close: 'Close',
quick: 'Two questions, one size',
recommended: 'Recommended',
soldOutL: 'sold out',
cmUnit: 'cm',
mm: 'mm',
},
de: {
stepOf: 'Frage {n} von {total}',
height: 'Wie groß bist du?',
weight: 'Wie viel wiegst du?',
build: 'Welche Statur kommt deiner am nächsten?',
fit: 'Wie soll es sitzen?',
builds: 'Schmal|Mittel|Kräftig',
buildNotes: 'Schmale Schultern und Taille|Schultern und Taille im Gleichgewicht|Breite Brust oder Schultern',
fits: 'Eng|Normal|Locker',
fitNotes: 'Folgt dem Körper|Raum zum Bewegen|Weit und leicht',
next: 'Weiter',
back: 'Zurück',
show: 'Meine Größe zeigen',
again: 'Neu beginnen',
yourSize: 'Deine Größe',
sure: '{pct} sicher',
sureLabel: 'Wie sicher wir sind',
between: 'Du liegst zwischen {a} und {b}. Für Lagen darunter nimm {b}.',
centre: 'Du liegst genau in der Mitte dieser Größe.',
take: 'Größe {size} wählen',
taken: 'Größe {size} gewählt',
basis: 'Gerechnet aus {n} Rücksendungen und Bewertungen dieses Schnitts',
units: 'Einheiten',
metric: 'cm, kg',
imperialU: 'ft, lb',
refTitle: 'Ein Teil, das dir gut passt',
refType: 'Was für ein Teil?',
refSize: 'Welche Größe hat es?',
refFeel: 'Wie sitzt es bei dir?',
feels: 'Etwas eng|Genau richtig|Etwas weit',
refResult: 'In diesem Schnitt nimm {size}',
refRuns: 'Unser Schnitt fällt {dir} aus als die meisten. Die Umrisse zeigen beide.',
larger: 'größer',
smaller: 'kleiner',
same: 'Unser Schnitt entspricht deinem.',
yours: 'Dein Teil',
ours: 'Unseres in {size}',
refMissing: 'Wähl die Art des Teils, seine Größe und wie es sitzt.',
footLength: 'Fußlänge',
footHint: 'Stell dich auf Papier, markier Ferse und längsten Zeh, miss die Linie. Zwischen {min} und {max} mm.',
footError: 'Gib eine Länge zwischen {min} und {max} mm ein, gemessen von der Ferse bis zum längsten Zeh.',
footEmpty: 'Gib deine Fußlänge in Millimetern ein.',
width: 'Fußbreite',
widths: 'Schmal|Normal|Breit',
socks: 'Deine Socken',
sockTypes: 'Dünn|Dick',
findShoe: 'Meine Größe finden',
shoeResult: 'Deine Größe',
halfUp: 'Breiter Fuß und dicke Socken, wir sind eine halbe Größe hoch gegangen.',
shoeNote: 'Ein Daumen Platz vor den Zehen, so soll es sein.',
sizeLabel: 'Größe',
sizeGuide: 'Größe finden',
close: 'Schließen',
quick: 'Zwei Fragen, eine Größe',
recommended: 'Empfohlen',
soldOutL: 'ausverkauft',
cmUnit: 'cm',
mm: 'mm',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const CHART: FitRow[] = [
{ size: 'XS', height: [155, 166], weight: [45, 56] },
{ size: 'S', height: [163, 173], weight: [54, 66] },
{ size: 'M', height: [170, 180], weight: [64, 77] },
{ size: 'L', height: [177, 187], weight: [75, 89] },
{ size: 'XL', height: [184, 194], weight: [87, 102] },
{ size: 'XXL', height: [190, 202], weight: [99, 118] },
];
const SHOES: ShoeRow[] = [
{ footMax: 240, eu: '38', us: '6', uk: '5' },
{ footMax: 247, eu: '39', us: '6.5', uk: '5.5' },
{ footMax: 253, eu: '40', us: '7', uk: '6.5' },
{ footMax: 260, eu: '41', us: '8', uk: '7' },
{ footMax: 267, eu: '42', us: '8.5', uk: '8' },
{ footMax: 273, eu: '43', us: '9.5', uk: '9' },
{ footMax: 280, eu: '44', us: '10', uk: '9.5' },
{ footMax: 287, eu: '45', us: '11', uk: '10.5' },
{ footMax: 293, eu: '46', us: '12', uk: '11' },
];
/** A position on the chart (0 = middle of the first size), from height and weight, nudged by build and fit */
function positionOf(chart: FitRow[], height: number, weight: number, build: number, fit: number) {
const pos = (v: number, key: 'height' | 'weight') => {
const mids = chart.map((r) => (r[key][0] + r[key][1]) / 2);
if (v <= mids[0]) return (v - mids[0]) / (mids[1] - mids[0]);
for (let i = 0; i < mids.length - 1; i++) if (v <= mids[i + 1]) return i + (v - mids[i]) / (mids[i + 1] - mids[i]);
const n = mids.length - 1;
return n + (v - mids[n]) / (mids[n] - mids[n - 1]);
};
// Weight says more about the fit of a top than height does
const p = pos(height, 'height') * 0.35 + pos(weight, 'weight') * 0.65 + (build - 1) * 0.35 + (fit - 1) * 0.45;
return Math.max(0, Math.min(chart.length - 1, p));
}
function recommendOf(chart: FitRow[], p: number) {
const i = Math.round(p);
const off = Math.abs(p - i);
const confidence = Math.round(96 - off * 70);
const other = p > i ? i + 1 : p < i ? i - 1 : i;
return { size: chart[i].size, index: i, confidence, between: off > 0.3 && other >= 0 && other < chart.length ? [chart[Math.min(i, other)].size, chart[Math.max(i, other)].size] : null };
}
const ftIn = (cm: number) => {
const inches = Math.round(cm / 2.54);
return `${Math.floor(inches / 12)}′ ${inches % 12}″`;
};
const lb = (kg: number) => `${Math.round(kg * 2.2046)} lb`;
/* ------------------------------------------------------------------ */
/* Drawings */
/* ------------------------------------------------------------------ */
/** A simple standing figure, narrow, average or broad */
function Figure({ build, active }: { build: number; active: boolean }) {
const s = [0.82, 1, 1.22][build];
const sh = round(26 * s);
const wa = round(17 * (0.85 + build * 0.15));
return (
<svg viewBox="0 0 100 140" className="h-24 w-auto" aria-hidden>
<circle cx="50" cy="22" r="11" className={cn('transition-colors', active ? 'fill-primary' : 'fill-[var(--border-strong)]')} />
<path
d={`M${50 - sh} 44 Q50 36 ${50 + sh} 44 L${50 + wa} 92 L${50 + wa - 4} 132 H${50 + 3} L50 100 L${50 - 3} 132 H${50 - wa + 4} L${50 - wa} 92 Z`}
className={cn('transition-colors', active ? 'fill-[color-mix(in_oklab,var(--primary)_75%,var(--background))]' : 'fill-[var(--border-strong)]')}
/>
</svg>
);
}
/** A tee outline, wider for a looser fit */
function TeeOutline({ width = 1, className, dashed }: { width?: number; className?: string; dashed?: boolean }) {
const w = round(30 * width);
const p = `M${50 - w} 30 L${50 - w - 22} 46 L${50 - w - 14} 62 L${50 - w} 54 L${50 - w + 2} 130 H${50 + w - 2} L${50 + w} 54 L${50 + w + 14} 62 L${50 + w + 22} 46 L${50 + w} 30 Q${50 + 12} 26 50 36 Q${50 - 12} 26 ${50 - w} 30 Z`;
return <path d={p} fill="none" strokeWidth="2" strokeLinejoin="round" strokeDasharray={dashed ? '5 4' : undefined} className={className} />;
}
function Ring({ value, t }: { value: number; t: Texts }) {
const locale = useLocale();
const [shown, setShown] = useState(0);
useEffect(() => {
const r = requestAnimationFrame(() => setShown(value));
return () => cancelAnimationFrame(r);
}, [value]);
const C = 2 * Math.PI * 34;
return (
<div className="relative grid size-24 shrink-0 place-items-center" role="img" aria-label={`${t.sureLabel}: ${formatPercent(value, locale)}`}>
<svg viewBox="0 0 80 80" className="absolute inset-0 size-full -rotate-90" aria-hidden>
<circle cx="40" cy="40" r="34" fill="none" strokeWidth="6" className="stroke-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" />
<circle cx="40" cy="40" r="34" fill="none" strokeWidth="6" strokeLinecap="round" strokeDasharray={`${round((C * shown) / 100)} ${round(C)}`} className="stroke-primary transition-[stroke-dasharray] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none" />
</svg>
<span className="relative text-[18px] font-semibold tabular-nums text-foreground" aria-hidden>
{formatPercent(value, locale)}
</span>
</div>
);
}
function Choice({ name, checked, onChange, children, className }: { name: string; checked: boolean; onChange: () => void; children: React.ReactNode; className?: string }) {
return (
<label className={cn('relative flex cursor-pointer flex-col items-center rounded-xl border p-3 text-center transition-[border-color,background-color,transform] duration-300 ease-[var(--ease-kit)] 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:-translate-y-0.5 hover:border-foreground/40', className)}>
<input type="radio" name={name} checked={checked} onChange={onChange} className="sr-only" />
{children}
</label>
);
}
function Chips({ name, items, value, onChange, legend, disabled, size = 'md' }: { name: string; items: string[]; value: number; onChange: (i: number) => void; legend: string; disabled?: string[]; size?: 'md' | 'lg' }) {
return (
<fieldset>
<legend className="mb-3 text-[14px] font-medium text-foreground">{legend}</legend>
<div className="flex flex-wrap gap-2">
{items.map((s, i) => (
<label key={s} className={cn('grid cursor-pointer place-items-center rounded-lg border px-4 text-[14px] 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', size === 'lg' ? 'h-12 min-w-14' : 'h-11', value === i ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-card text-foreground hover:border-foreground/50', disabled?.includes(s) && 'cursor-not-allowed border-dashed opacity-45')}>
<input type="radio" name={name} checked={value === i} disabled={disabled?.includes(s)} onChange={() => onChange(i)} className="sr-only" />
{s}
</label>
))}
</div>
</fieldset>
);
}
function Head({ props }: { props: FitFinderProps }) {
if (!props.title) 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>}
<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 Slider({ label, value, min, max, onChange, display }: { label: string; value: number; min: number; max: number; onChange: (v: number) => void; display: string }) {
const id = useId();
const pct = ((value - min) / (max - min)) * 100;
return (
<div>
<label htmlFor={id} className="sr-only">
{label}
</label>
<p className="text-center text-[44px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground sm:text-[56px]" aria-hidden>
{display}
</p>
<div className="relative mt-8">
<div className="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-track" aria-hidden>
<div className="h-full rounded-full bg-primary" style={{ width: `${round(pct)}%` }} />
</div>
<input id={id} type="range" min={min} max={max} value={value} onChange={(e) => onChange(Number(e.target.value))} aria-valuetext={display} className="kit-fit-range relative h-11 w-full cursor-pointer appearance-none bg-transparent" />
</div>
<style href="kit-fit-range" precedence="default">{`.kit-fit-range::-webkit-slider-thumb{appearance:none;width:28px;height:28px;border-radius:999px;background:var(--background);border:2px solid var(--primary);box-shadow:var(--shadow-md)}.kit-fit-range::-moz-range-thumb{width:24px;height:24px;border-radius:999px;background:var(--background);border:2px solid var(--primary);box-shadow:var(--shadow-md)}.kit-fit-range::-webkit-slider-runnable-track{background:transparent}.kit-fit-range:focus-visible{outline:none}.kit-fit-range:focus-visible::-webkit-slider-thumb{outline:2px solid var(--ring);outline-offset:2px}.kit-fit-range:focus-visible::-moz-range-thumb{outline:2px solid var(--ring);outline-offset:2px}`}</style>
</div>
);
}
/* ------------------------------------------------------------------ */
/* steps */
/* ------------------------------------------------------------------ */
function Steps(props: FitFinderProps & { t: Texts }) {
const { t } = props;
const locale = useLocale();
const chart = props.chart ?? CHART;
const [imperial, setImperial] = useState(props.imperial ?? locale === 'en');
const [step, setStep] = useState(0);
const [height, setHeight] = useState(176);
const [weight, setWeight] = useState(72);
const [build, setBuild] = useState(1);
const [fit, setFit] = useState(1);
const [chosen, setChosen] = useState(false);
const heading = useRef<HTMLHeadingElement>(null);
const name = useId();
const go = (i: number) => {
setStep(i);
setChosen(false);
requestAnimationFrame(() => heading.current?.focus());
};
const rec = props.recommend ? { ...props.recommend({ height, weight, build, fit }), between: null as string[] | null } : recommendOf(chart, positionOf(chart, height, weight, build, fit));
const titles = [t.height, t.weight, t.build, t.fit];
const builds = t.builds.split('|');
const buildNotes = t.buildNotes.split('|');
const fits = t.fits.split('|');
const fitNotes = t.fitNotes.split('|');
return (
<section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mx-auto max-w-[40rem]">
<Head props={props} />
<div className="overflow-hidden rounded-3xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
<div className="flex items-center gap-3 border-b border-border px-5 py-3 sm:px-7">
<p className="text-[13px] font-medium text-muted-foreground">{step < 4 ? fill(t.stepOf, { n: step + 1, total: 4 }) : t.yourSize}</p>
<div className="ml-auto flex gap-1" aria-hidden>
{[0, 1, 2, 3].map((i) => (
<span key={i} className={cn('h-1.5 rounded-full transition-[width,background-color] duration-500 ease-[var(--ease-kit)]', i === step ? 'w-6 bg-primary' : i < step ? 'w-1.5 bg-primary' : 'w-1.5 bg-track')} />
))}
</div>
</div>
<div key={step} className="animate-[kitshop-next_0.4s_var(--ease-kit)] px-5 py-8 sm:px-10 sm:py-10">
{step < 4 ? (
<>
<h3 ref={heading} tabIndex={-1} className="text-center text-[22px] font-semibold tracking-[-0.02em] text-foreground outline-none sm:text-[26px]">
{titles[step]}
</h3>
<div className="mt-8">
{step === 0 && <Slider label={t.height} value={height} min={150} max={205} onChange={setHeight} display={imperial ? ftIn(height) : `${height} cm`} />}
{step === 1 && <Slider label={t.weight} value={weight} min={42} max={125} onChange={setWeight} display={imperial ? lb(weight) : `${weight} kg`} />}
{step === 2 && (
<fieldset>
<legend className="sr-only">{t.build}</legend>
<div className="grid grid-cols-3 gap-2 sm:gap-3">
{builds.map((b, i) => (
<Choice key={b} name={`${name}-b`} checked={build === i} onChange={() => setBuild(i)}>
<Figure build={i} active={build === i} />
<span className="mt-2 text-[14px] font-medium text-foreground">{b}</span>
<span className="mt-0.5 text-[11px] leading-snug text-muted-foreground max-sm:hidden">{buildNotes[i]}</span>
</Choice>
))}
</div>
</fieldset>
)}
{step === 3 && (
<fieldset>
<legend className="sr-only">{t.fit}</legend>
<div className="grid grid-cols-3 gap-2 sm:gap-3">
{fits.map((f, i) => (
<Choice key={f} name={`${name}-f`} checked={fit === i} onChange={() => setFit(i)}>
<svg viewBox="0 0 100 140" className="h-24 w-auto" aria-hidden>
<TeeOutline width={0.8 + i * 0.22} className={cn('transition-colors', fit === i ? 'stroke-primary' : 'stroke-[var(--border-strong)]')} />
</svg>
<span className="mt-2 text-[14px] font-medium text-foreground">{f}</span>
<span className="mt-0.5 text-[11px] leading-snug text-muted-foreground max-sm:hidden">{fitNotes[i]}</span>
</Choice>
))}
</div>
</fieldset>
)}
</div>
{step < 2 && (
<div className="mt-6 flex justify-center">
<div role="radiogroup" aria-label={t.units} className="inline-flex rounded-full border border-border bg-surface p-1">
{[false, true].map((imp) => (
<button key={String(imp)} type="button" role="radio" aria-checked={imperial === imp} onClick={() => setImperial(imp)} className={cn('h-9 rounded-full px-4 text-[13px] font-medium transition-colors max-sm:h-11', imperial === imp ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}>
{imp ? t.imperialU : t.metric}
</button>
))}
</div>
</div>
)}
</>
) : (
<div className="text-center">
<h3 ref={heading} tabIndex={-1} className="text-[14px] font-medium text-muted-foreground outline-none">
{t.yourSize}
</h3>
<div className="mt-4 flex items-center justify-center gap-6">
<p className="animate-[kitshop-pop_0.6s_var(--ease-kit)] text-[72px] font-semibold leading-none tracking-[-0.04em] text-foreground sm:text-[88px]">{rec.size}</p>
<Ring value={rec.confidence} t={t} />
</div>
<p className="mx-auto mt-5 max-w-[26rem] text-pretty text-[15px] text-foreground">{rec.between ? fill(t.between, { a: rec.between[0], b: rec.between[1] }) : t.centre}</p>
{/* The scale of sizes with a marker where you sit */}
<div className="mx-auto mt-7 max-w-[26rem]" aria-hidden>
<div className="grid" style={{ gridTemplateColumns: `repeat(${chart.length}, minmax(0, 1fr))` }}>
{chart.map((r) => (
<span key={r.size} className={cn('border-t-2 pt-2 text-[12px] font-medium', r.size === rec.size ? 'border-primary text-foreground' : 'border-border text-muted-foreground')}>
{r.size}
</span>
))}
</div>
</div>
<p className="mt-6 text-[12px] text-muted-foreground">{fill(t.basis, { n: locale === 'de' ? '8.400' : '8,400' })}</p>
</div>
)}
</div>
<div className="flex items-center gap-3 border-t border-border bg-surface px-5 py-4 sm:px-7">
{step > 0 && (
<Button variant="outline" iconLeft={step < 4 ? 'arrow-left' : 'rotate-ccw'} onClick={() => go(step < 4 ? step - 1 : 0)}>
{step < 4 ? t.back : t.again}
</Button>
)}
{step < 4 ? (
<Button onClick={() => go(step + 1)} icon="arrow-right" className="ml-auto">
{step === 3 ? t.show : t.next}
</Button>
) : (
<Button onClick={() => { setChosen(true); props.onPick?.(rec.size); }} icon={chosen ? undefined : 'arrow-right'} iconLeft={chosen ? 'check' : undefined} variant={chosen ? 'secondary' : 'primary'} className="ml-auto">
{fill(chosen ? t.taken : t.take, { size: rec.size })}
</Button>
)}
</div>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* reference */
/* ------------------------------------------------------------------ */
function Reference(props: FitFinderProps & { t: Texts }) {
const { t } = props;
const chart = props.chart ?? CHART;
const sizes = chart.map((r) => r.size);
const refs = props.references ?? [];
const [type, setType] = useState(-1);
const [size, setSize] = useState(-1);
const [feel, setFeel] = useState(-1);
const [show, setShow] = useState(false);
const name = useId();
const box = useRef<HTMLDivElement>(null);
const missing = type < 0 || size < 0 || feel < 0;
useShake(box, show && missing);
const offset = type >= 0 ? (refs[type]?.offset ?? 0) : 0;
const target = Math.max(0, Math.min(sizes.length - 1, size + (feel === 0 ? 1 : feel === 2 ? -1 : 0) - offset));
const ready = !missing;
const feels = t.feels.split('|');
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,1fr)_minmax(0,25rem)] lg:gap-14">
<div ref={box} className="min-w-0 space-y-8">
<h3 className="text-[19px] font-semibold text-foreground">{t.refTitle}</h3>
<Chips name={`${name}-t`} legend={t.refType} items={refs.map((r) => r.label)} value={type} onChange={(i) => { setType(i); setShow(false); }} />
<Chips name={`${name}-s`} legend={t.refSize} items={sizes} value={size} onChange={(i) => { setSize(i); setShow(false); }} size="lg" />
<Chips name={`${name}-f`} legend={t.refFeel} items={feels} value={feel} onChange={(i) => { setFeel(i); setShow(false); }} />
{show && missing && (
<p role="alert" className="flex gap-2 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_9%,var(--card))] px-3 py-2.5 text-[13px] text-foreground">
<Icon name="alert-circle" size={16} className="mt-0.5 shrink-0 text-destructive" />
{t.refMissing}
</p>
)}
<Button onClick={() => setShow(true)} icon="arrow-right" className="max-sm:w-full">
{t.findShoe}
</Button>
</div>
<div className="lg:sticky lg:top-8 lg:self-start">
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6" aria-live="polite">
<div data-clip-ok className="relative mx-auto aspect-[10/11] max-w-[18rem]">
<svg viewBox="0 0 160 150" className="size-full" aria-hidden>
<g transform="translate(30 6)">
<TeeOutline width={size >= 0 ? 0.8 + size * 0.07 + (feel === 0 ? -0.04 : feel === 2 ? 0.05 : 0) : 1} dashed className="stroke-muted-foreground transition-all" />
{show && ready && <TeeOutline width={0.8 + target * 0.07 + offset * 0.04} className="animate-[kitshop-fade_0.5s_var(--ease-kit)] stroke-primary" />}
</g>
</svg>
</div>
<ul className="mt-2 flex flex-wrap justify-center gap-x-5 gap-y-1 text-[12px] text-muted-foreground">
<li className="flex items-center gap-1.5">
<span aria-hidden className="w-5 border-t-2 border-dashed border-muted-foreground" />
{t.yours}
{size >= 0 ? ` · ${sizes[size]}` : ''}
</li>
{show && ready && (
<li className="flex items-center gap-1.5">
<span aria-hidden className="w-5 border-t-2 border-primary" />
{fill(t.ours, { size: sizes[target] })}
</li>
)}
</ul>
{show && ready && (
<div className="mt-5 animate-[kitshop-in_0.45s_var(--ease-kit)] border-t border-border pt-5 text-center">
<p className="text-[22px] font-semibold tracking-[-0.02em] text-foreground">{fill(t.refResult, { size: sizes[target] })}</p>
<p className="mt-1.5 text-pretty text-[13px] text-muted-foreground">{offset ? fill(t.refRuns, { dir: offset > 0 ? t.larger : t.smaller }) : t.same}</p>
<Button variant="outline" className="mt-4 w-full" onClick={() => props.onPick?.(sizes[target])}>
{fill(t.take, { size: sizes[target] })}
</Button>
</div>
)}
</div>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* shoes */
/* ------------------------------------------------------------------ */
function FootGuide({ length }: { length?: number }) {
return (
<svg viewBox="0 0 200 260" className="h-full w-auto" aria-hidden>
<rect x="30" y="10" width="140" height="240" rx="4" className="fill-background stroke-[var(--border-strong)]" />
<path d="M100 222c-26 0-38-20-38-52 0-36 6-70 10-96 4-28 14-44 30-44s24 16 26 40c2 22-4 40-2 62 2 22 10 34 10 52 0 22-14 38-36 38Z" className="fill-[color-mix(in_oklab,var(--foreground)_8%,var(--background))] stroke-[var(--border-strong)]" strokeWidth="1.5" />
{[[86, 28, 9], [102, 24, 8], [115, 30, 6.5], [125, 38, 5.5], [133, 48, 5]].map(([cx, cy, r], i) => (
<circle key={i} cx={cx} cy={cy} r={r} className="fill-[color-mix(in_oklab,var(--foreground)_8%,var(--background))] stroke-[var(--border-strong)]" strokeWidth="1.2" />
))}
<path d="M46 16H154M46 232H154" strokeDasharray="4 4" className="stroke-primary" strokeWidth="1.5" />
<path d="M160 18V230M155 23l5-5 5 5M155 225l5 5 5-5" fill="none" className="stroke-primary" strokeWidth="1.5" />
{length ? (
<text x="166" y="128" fontSize="12" fontWeight="600" textAnchor="start" transform="rotate(90 166 128)" className="fill-primary">
{length} mm
</text>
) : null}
</svg>
);
}
function Shoes(props: FitFinderProps & { t: Texts }) {
const { t } = props;
const chart = props.shoes ?? SHOES;
const min = 220;
const max = chart[chart.length - 1].footMax + 10;
const id = useId();
const [raw, setRaw] = useState('');
const [width, setWidth] = useState(1);
const [socks, setSocks] = useState(0);
const [error, setError] = useState('');
const [result, setResult] = useState<{ row: ShoeRow; up: boolean } | null>(null);
const form = useRef<HTMLFormElement>(null);
const input = useRef<HTMLInputElement>(null);
useShake(form, !!error);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const n = Number(raw.replace(',', '.'));
if (!raw.trim()) return (setError(t.footEmpty), input.current?.focus());
if (!Number.isFinite(n) || n < min || n > max) return (setError(fill(t.footError, { min, max })), input.current?.focus());
setError('');
const extra = (width === 2 ? 4 : width === 0 ? -2 : 0) + (socks === 1 ? 3 : 0);
const len = n + extra;
const row = chart.find((r) => len <= r.footMax) ?? chart[chart.length - 1];
const plain = chart.find((r) => n <= r.footMax) ?? chart[chart.length - 1];
setResult({ row, up: row !== plain });
};
const n = Number(raw.replace(',', '.'));
const widths = t.widths.split('|');
const sockTypes = t.sockTypes.split('|');
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 items-start gap-8 md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] lg:grid-cols-[18rem_minmax(0,1fr)_minmax(0,20rem)] lg:gap-12">
<div className="mx-auto h-56 rounded-2xl bg-surface-2 p-4 md:h-80 md:w-full" data-clip-ok>
<div className="flex h-full justify-center">
<FootGuide length={Number.isFinite(n) && n >= min && n <= max ? n : undefined} />
</div>
</div>
<form ref={form} onSubmit={submit} noValidate className="min-w-0 space-y-7">
<div>
<label htmlFor={id} className="mb-1.5 block text-[14px] font-medium text-foreground">
{t.footLength}
</label>
<div className="relative max-w-[12rem]">
<input ref={input} id={id} inputMode="numeric" value={raw} onChange={(e) => { setRaw(e.target.value); setError(''); setResult(null); }} placeholder="265" aria-invalid={!!error || undefined} aria-describedby={`${id}-h`} className="h-12 w-full rounded-lg border border-input bg-background pl-4 pr-12 text-[18px] tabular-nums 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))]" />
<span className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-[14px] text-muted-foreground">{t.mm}</span>
</div>
<p id={`${id}-h`} aria-live="polite" className={cn('mt-1.5 max-w-[28rem] text-[12px]', error ? 'text-destructive' : 'text-muted-foreground')}>
{error || fill(t.footHint, { min, max })}
</p>
</div>
<Chips name={`${id}-w`} legend={t.width} items={widths} value={width} onChange={(i) => { setWidth(i); setResult(null); }} />
<Chips name={`${id}-s`} legend={t.socks} items={sockTypes} value={socks} onChange={(i) => { setSocks(i); setResult(null); }} />
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{t.findShoe}
</Button>
</form>
<div className="md:col-span-2 lg:col-span-1" aria-live="polite">
{result ? (
<div className="animate-[kitshop-zoom_0.5s_var(--ease-kit)] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-6">
<p className="text-[13px] font-medium text-muted-foreground">{t.shoeResult}</p>
<dl className="mt-3 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border">
{(['eu', 'us', 'uk'] as const).map((k, i) => (
<div key={k} className={cn('bg-card px-2 py-3 text-center', i === 0 && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]')}>
<dt className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{k}</dt>
<dd className={cn('mt-1 font-semibold tabular-nums text-foreground', i === 0 ? 'text-[32px] leading-none' : 'text-[22px] leading-tight')}>{result.row[k]}</dd>
</div>
))}
</dl>
<p className="mt-4 text-pretty text-[13px] text-muted-foreground">{result.up ? t.halfUp : t.shoeNote}</p>
<Button variant="outline" className="mt-4 w-full" onClick={() => props.onPick?.(result.row.eu)}>
{fill(t.take, { size: result.row.eu })}
</Button>
</div>
) : (
<div className="rounded-2xl border border-dashed border-border-strong p-5 max-md:hidden sm:p-6">
<p className="text-[13px] font-medium text-muted-foreground">{t.shoeResult}</p>
<dl className="mt-3 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border" aria-hidden>
{(['eu', 'us', 'uk'] as const).map((k) => (
<div key={k} className="bg-background px-2 py-3 text-center">
<dt className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{k}</dt>
<dd className="mt-1 text-[22px] font-semibold leading-tight text-subtle-foreground">·</dd>
</div>
))}
</dl>
<p className="mt-4 text-[13px] text-muted-foreground">{t.shoeNote}</p>
</div>
)}
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* inline */
/* ------------------------------------------------------------------ */
function Inline(props: FitFinderProps & { t: Texts }) {
const { t } = props;
const locale = useLocale();
const chart = props.chart ?? CHART;
const [open, setOpen] = useState(false);
const [height, setHeight] = useState(172);
const [fit, setFit] = useState(1);
const [rec, setRec] = useState<string | null>(null);
const [size, setSize] = useState<string | null>(null);
const panel = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const name = useId();
const imperial = props.imperial ?? locale === 'en';
useEffect(() => {
if (!open) return;
const focusTimer = setTimeout(() => panel.current?.querySelector<HTMLElement>('input')?.focus(), 60);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { setOpen(false); trigger.current?.focus(); }
};
const onDown = (e: PointerEvent) => {
if (panel.current && !panel.current.contains(e.target as Node) && !trigger.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown);
return () => { clearTimeout(focusTimer); window.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onDown); };
}, [open]);
const apply = () => {
// Height alone, weight assumed from the middle of the chart row for that height
const row = chart.find((r) => height <= r.height[1]) ?? chart[chart.length - 1];
const w = (row.weight[0] + row.weight[1]) / 2;
const r = props.recommend ? props.recommend({ height, weight: w, build: 1, fit }) : recommendOf(chart, positionOf(chart, height, w, 1, fit));
const pick = props.soldOut?.includes(r.size) ? (chart.find((x, i) => i > chart.findIndex((y) => y.size === r.size) && !props.soldOut?.includes(x.size))?.size ?? r.size) : r.size;
setRec(pick);
setSize(pick);
setOpen(false);
props.onPick?.(pick);
trigger.current?.focus();
};
const fits = t.fits.split('|');
return (
<section className={cn('py-14 sm:py-20', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mx-auto max-w-[30rem]">
{props.title && <h2 className="text-h3 font-semibold text-foreground">{props.title}</h2>}
{props.description && <p className="mt-2 text-[15px] text-muted-foreground">{props.description}</p>}
<div className="relative mt-6">
<div className="mb-3 flex items-center justify-between gap-3">
<p id={`${name}-l`} className="text-[14px] font-medium text-foreground">
{t.sizeLabel}
{size && <span className="ml-2 font-normal text-muted-foreground">{size}</span>}
</p>
<button ref={trigger} type="button" aria-expanded={open} aria-controls={`${name}-p`} aria-haspopup="dialog" onClick={() => setOpen(!open)} className="inline-flex h-11 items-center gap-1.5 rounded-md px-1 text-[13px] font-medium text-foreground underline decoration-border-strong underline-offset-4 hover:decoration-foreground">
<Icon name="ruler" size={15} />
{t.sizeGuide}
</button>
</div>
<div role="radiogroup" aria-labelledby={`${name}-l`} className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{chart.map((r) => {
const out = props.soldOut?.includes(r.size);
const on = size === r.size;
return (
<label key={r.size} className={cn('relative grid h-12 cursor-pointer place-items-center rounded-lg border text-[14px] font-medium transition-[border-color,background-color,color] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-card text-foreground hover:border-foreground/50', out && 'cursor-not-allowed border-dashed text-subtle-foreground hover:border-border-strong')}>
<input type="radio" name={`${name}-size`} checked={on} disabled={out} onChange={() => setSize(r.size)} className="sr-only" />
<span className={cn(out && 'line-through')}>{r.size}</span>
{out && <span className="sr-only">, {t.soldOutL}</span>}
{rec === r.size && (
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 animate-[kitshop-pop_0.45s_var(--ease-kit)] whitespace-nowrap rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-[18px] text-primary-foreground">
{t.recommended}
</span>
)}
</label>
);
})}
</div>
<div
ref={panel}
id={`${name}-p`}
role="dialog"
aria-label={t.quick}
inert={!open}
className={cn('absolute inset-x-0 top-14 z-20 origin-top rounded-2xl border border-border bg-popover p-5 text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)] transition-[opacity,transform,visibility] duration-250 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'visible scale-100 opacity-100' : 'invisible scale-[0.97] opacity-0')}
>
<div className="flex items-start justify-between gap-3">
<p className="text-[15px] font-semibold text-foreground">{t.quick}</p>
<button type="button" onClick={() => { setOpen(false); trigger.current?.focus(); }} aria-label={t.close} className="-m-2 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground">
<Icon name="x" size={17} />
</button>
</div>
<label htmlFor={`${name}-h`} className="mt-3 flex items-baseline justify-between text-[13px] font-medium text-foreground">
{t.height}
<span className="text-[15px] font-semibold tabular-nums">{imperial ? ftIn(height) : `${height} cm`}</span>
</label>
<input id={`${name}-h`} type="range" min={150} max={205} value={height} onChange={(e) => setHeight(Number(e.target.value))} aria-valuetext={imperial ? ftIn(height) : `${height} cm`} className="mt-1 h-11 w-full cursor-pointer accent-[var(--primary)]" />
<fieldset className="mt-3">
<legend className="mb-2 text-[13px] font-medium text-foreground">{t.fit}</legend>
<div className="grid grid-cols-3 gap-2">
{fits.map((f, i) => (
<label key={f} className={cn('grid h-11 cursor-pointer place-items-center rounded-lg border 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', fit === i ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
<input type="radio" name={`${name}-fit`} checked={fit === i} onChange={() => setFit(i)} className="sr-only" />
{f}
</label>
))}
</div>
</fieldset>
<Button onClick={apply} icon="arrow-right" className="mt-5 w-full">
{t.show}
</Button>
</div>
</div>
<p className="mt-4 text-[12px] text-muted-foreground" aria-live="polite">
{rec ? fill(t.taken, { size: rec }) : ''}
</p>
</div>
</div>
</section>
);
}
/**
* A size advisor that asks, then names one size: four questions with a
* confidence ring, a comparison with a piece you own, a shoe size from the
* foot length, or two quick questions right at the size row.
*/
export function FitFinder(props: FitFinderProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'steps' } = props;
if (variant === 'reference') return <Reference {...props} t={t} />;
if (variant === 'shoes') return <Shoes {...props} t={t} />;
if (variant === 'inline') return <Inline {...props} t={t} />;
return <Steps {...props} t={t} />;
}Frequent questions
How do I install Fit finder?
With aiw add @ai-website/fit-finder. The command puts components/ui/fit-finder.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 Fit finder have?
4 variants, Four questions, Compared with yours, Shoe size and At the size row. You pick one with the variant prop, for example variant="reference".
Can I translate the texts of Fit finder?
Yes. Fit finder carries 57 texts of its own in English and German, such as “How tall are you?”, “What do you weigh?” and “Which build is closest to yours?”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Fit finder?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Fit finder with aiw_components and install it with aiw_add @ai-website/fit-finder. Name the variant in your prompt, for example variant="steps", and the assistant writes your content straight into the props.