Zum Inhalt springen
UI Kit
Accent

Größenberater

Ein Größenberater, der eine Größe nennt und wie sicher sie ist. Nach Statur, einem eigenen Teil, Fußlänge oder an der Größenwahl.

4 VariantsEine von 4 Components in Product Pages

Eine Größe und wie sicher sie ist

Die richtige Größe ist der sicherste Weg zu einer Bestellung, die bleibt. Dieser Größenberater nennt eine Größe und wie sicher die Empfehlung ist, Kunden bestellen also einmal und behalten.

„Vier Fragen“ nimmt Größe und Gewicht auf einem großen Regler in metrischen oder imperialen Einheiten auf, dazu gezeichnete Staturen und Passformen, und zeigt das Ergebnis mit Sicherheitsring und einer Skala aller Größen. „Im Vergleich zu deinem“ geht von einem eigenen Teil aus und zeichnet die empfohlene Größe über dessen gestrichelten Umriss, eine Zeile sagt, ob der Schnitt größer ausfällt. „Schuhgröße“ zeigt, wie man misst, markiert die Länge auf dem Papier und nennt das Ergebnis in drei Systemen. „An der Größenauswahl“ ergänzt die Größenwahl einer Produktseite um einen Link, ein kleines Panel fragt Größe und Passform ab und wählt die empfohlene Größe.

Setz den Link direkt an die Größenauswahl, dort entsteht der Zweifel. Sag, wenn ein Schnitt größer oder kleiner ausfällt. Zeig die Sicherheit offen, ein klares „zwischen zwei Größen“ wirkt wie ehrliche Beratung.

Variants

Vier FragenNeu in 1.2

Größe und Gewicht auf einem großen Regler mit metrischen oder angloamerikanischen Einheiten, gezeichnete Staturen und Passformen zur Wahl. Das Ergebnis springt mit einem Ring auf, der die Sicherheit füllt, und einer Skala aller Größen.

Im Vergleich zu deinemNeu in 1.2

Wähl ein Teil, das du hast, seine Größe und wie es sitzt. Dein Teil steht als gestrichelter Umriss, unseres in der empfohlenen Größe legt sich darüber, und eine Zeile sagt, ob unser Schnitt größer ausfällt.

SchuhgrößeNeu in 1.2

Eine gezeichnete Anleitung zeigt, wie man misst, die Länge steht beim Tippen auf dem Papier. Breite und Socken verschieben das Ergebnis, das in drei Systemen erscheint, das wichtigste groß.

An der GrößenauswahlNeu in 1.2

Die Größenauswahl einer Produktseite mit dem Link „Größe finden“. Ein kleines Feld fragt Größe und Passform, schließt mit Escape oder Klick daneben, und die empfohlene Größe bekommt ein Abzeichen und wird gewählt.

Details

Was Größenberater mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Product Pages
Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/fit-finder.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 57 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle FitFinderProps aus components/ui/fit-finder.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FitFinderVariant

    stepsreferenceshoesinline

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • chartoptional
    FitRow[]

    steps, reference, inline: the size chart, smallest first

  • shoesoptional
    ShoeRow[]

    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)

  • soldOutoptional
    string[]

    inline: sizes that are sold out

  • imperialoptional
    boolean

    Imperial 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

Eingebaute Texte

Diese Texte schreibt Größenberater selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 57 Texte anzeigen
stepOfEnglisch Question {n} of {total}Deutsch Frage {n} von {total}
heightEnglisch How tall are you?Deutsch Wie groß bist du?
weightEnglisch What do you weigh?Deutsch Wie viel wiegst du?
buildEnglisch Which build is closest to yours?Deutsch Welche Statur kommt deiner am nächsten?
fitEnglisch How do you like it to sit?Deutsch Wie soll es sitzen?
buildsEnglisch Narrow|Average|BroadDeutsch Schmal|Mittel|Kräftig
buildNotesEnglisch Slim shoulders and waist|Shoulders and waist in balance|Wide chest or shouldersDeutsch Schmale Schultern und Taille|Schultern und Taille im Gleichgewicht|Breite Brust oder Schultern
fitsEnglisch Close|Regular|RelaxedDeutsch Eng|Normal|Locker
fitNotesEnglisch Follows the body|Room to move|Loose and easyDeutsch Folgt dem Körper|Raum zum Bewegen|Weit und leicht
nextEnglisch NextDeutsch Weiter
backEnglisch BackDeutsch Zurück
showEnglisch Show my sizeDeutsch Meine Größe zeigen
againEnglisch Start overDeutsch Neu beginnen
yourSizeEnglisch Your sizeDeutsch Deine Größe
sureEnglisch {pct} sureDeutsch {pct} sicher
sureLabelEnglisch How sure we areDeutsch Wie sicher wir sind
betweenEnglisch You are between {a} and {b}. For layers underneath, take {b}.Deutsch Du liegst zwischen {a} und {b}. Für Lagen darunter nimm {b}.
centreEnglisch You sit right in the middle of this size.Deutsch Du liegst genau in der Mitte dieser Größe.
takeEnglisch Choose size {size}Deutsch Größe {size} wählen
takenEnglisch Size {size} chosenDeutsch Größe {size} gewählt
basisEnglisch Based on {n} returns and reviews of this cutDeutsch Gerechnet aus {n} Rücksendungen und Bewertungen dieses Schnitts
unitsEnglisch UnitsDeutsch Einheiten
metricEnglisch cm, kgDeutsch cm, kg
imperialUEnglisch ft, lbDeutsch ft, lb
refTitleEnglisch A piece you own that fits wellDeutsch Ein Teil, das dir gut passt
refTypeEnglisch What kind of piece?Deutsch Was für ein Teil?
refSizeEnglisch Which size is it?Deutsch Welche Größe hat es?
refFeelEnglisch How does it fit you?Deutsch Wie sitzt es bei dir?
feelsEnglisch A bit tight|Just right|A bit looseDeutsch Etwas eng|Genau richtig|Etwas weit
refResultEnglisch In this cut, take {size}Deutsch In diesem Schnitt nimm {size}
refRunsEnglisch Our cut runs {dir} than most. The outlines show both.Deutsch Unser Schnitt fällt {dir} aus als die meisten. Die Umrisse zeigen beide.
largerEnglisch largerDeutsch größer
smallerEnglisch smallerDeutsch kleiner
sameEnglisch Our cut matches yours.Deutsch Unser Schnitt entspricht deinem.
yoursEnglisch Your pieceDeutsch Dein Teil
oursEnglisch Ours in {size}Deutsch Unseres in {size}
refMissingEnglisch Choose the kind of piece, its size and how it fits.Deutsch Wähl die Art des Teils, seine Größe und wie es sitzt.
footLengthEnglisch Foot lengthDeutsch Fußlänge
footHintEnglisch Stand on paper, mark heel and longest toe, measure the line. Between {min} and {max} mm.Deutsch Stell dich auf Papier, markier Ferse und längsten Zeh, miss die Linie. Zwischen {min} und {max} mm.
footErrorEnglisch Enter a length between {min} and {max} mm, measured from heel to the longest toe.Deutsch Gib eine Länge zwischen {min} und {max} mm ein, gemessen von der Ferse bis zum längsten Zeh.
footEmptyEnglisch Enter your foot length in millimetres.Deutsch Gib deine Fußlänge in Millimetern ein.
widthEnglisch Foot widthDeutsch Fußbreite
widthsEnglisch Narrow|Regular|WideDeutsch Schmal|Normal|Breit
socksEnglisch Socks you wearDeutsch Deine Socken
sockTypesEnglisch Thin|ThickDeutsch Dünn|Dick
findShoeEnglisch Find my sizeDeutsch Meine Größe finden
shoeResultEnglisch Your sizeDeutsch Deine Größe
halfUpEnglisch For wide feet and thick socks we went half a size up.Deutsch Breiter Fuß und dicke Socken, wir sind eine halbe Größe hoch gegangen.
shoeNoteEnglisch A thumb of room in front of the toes, that is how it should be.Deutsch Ein Daumen Platz vor den Zehen, so soll es sein.
sizeLabelEnglisch SizeDeutsch Größe
sizeGuideEnglisch Find my sizeDeutsch Größe finden
closeEnglisch CloseDeutsch Schließen
quickEnglisch Two questions, one sizeDeutsch Zwei Fragen, eine Größe
recommendedEnglisch RecommendedDeutsch Empfohlen
soldOutLEnglisch sold outDeutsch ausverkauft
cmUnitEnglisch cmDeutsch cm
mmEnglisch mmDeutsch mm

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Größenberater reagiert selbst auf die Tasten Escape.
  • Setzt aria-checked, aria-controls, aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, dialog, img, radio und radiogroup.
  • 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

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 821 Zeilen
components/ui/fit-finder.tsx
'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} />;
}

Häufige Fragen

Wie installiere ich Größenberater?

Mit aiw add @ai-website/fit-finder. Der Befehl legt die Datei components/ui/fit-finder.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, product-image und input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Größenberater?

4 Varianten, Vier Fragen, Im Vergleich zu deinem, Schuhgröße und An der Größenauswahl. Du wählst sie mit der Prop variant, zum Beispiel variant="reference".

Kann ich die Texte von Größenberater übersetzen?

Ja. Größenberater bringt 57 eigene Texte auf Deutsch und Englisch mit, etwa „Wie groß bist du?“, „Wie viel wiegst du?“ und „Welche Statur kommt deiner am nächsten?“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Größenberater einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Größenberater mit aiw_components und installieren sie mit aiw_add @ai-website/fit-finder. Nenn im Prompt die Variante, etwa variant="steps", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert