Skip to content
UI Kit
Accent color

Size guide

Size charts in cm or inches, a fit finder from height and weight, a drawn figure that shows how to measure, a shoe size converter and the guide in a dialog from the size picker.

One of 2 components in Product pages

5 variants

Variants

Size chart

Body measures per size with EU, US and UK names. A switch with a sliding thumb turns cm into inches and every value rolls in, hover or tap draws a crosshair through row and column. On phones the first column stays while the table scrolls.

Open in Studio

Fit finder

Height and weight on large sliders, the fit as a three way switch. The recommended size swaps with a short rise, the bars of what similar people chose grow when they scroll in and glide on every change.

Open in Studio

How to measure

A drawn figure whose measuring lines draw themselves one after another. Each step opens with a soft height change and lights up its line, on desktop on hover, on phones on tap, and the numbers on the figure pick a step too.

Open in Studio

Shoe size converter

Foot length on a slider next to a drawn foot that grows along a ruler. The EU, US and UK sizes swap with a rise and the matching row of the table lights up.

Open in Studio

Guide from the size picker

The size picker of a product page with a "Size guide" link. It opens a dialog that slides up on phones, with the chart (the picked size is marked) and the measuring figure in tabs and the unit switch in the head.

Open in Studio

Details

What Size guide brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/size-guide.tsx
Brings along
utilskitbuttoniconrevealsectiontabsdialogproduct-image
npm packages
None
Languages
English and German, 27 texts of its own

Props

The SizeGuideProps interface from components/ui/size-guide.tsx, with the comments from the code.

  • variantoptional
    SizeGuideVariant

    tablefit-finderhow-to-measureshoe-converterdialog

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • chartoptional
    SizeChart
  • fitSizesoptional
    FitSize[]

    fit-finder

  • defaultHeightoptional
    number
  • defaultWeightoptional
    number
  • stepsoptional
    MeasureStep[]
  • shoesoptional
    ShoeRow[]
  • defaultFootoptional
    number
  • productoptional
    { name: string; variant?: string; image?: React.ReactNode }

    dialog: the product the picker belongs to

  • defaultOpenoptional
    boolean

    dialog: open on load (previews)

  • defaultUnitoptional
    'cm' | 'in'
  • onPickoptional
    (size: string) => void

Built-in texts

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

Show all 27 texts
unitEnglish UnitGerman Einheit
sizeEnglish SizeGerman Größe
measureEnglish MeasureGerman Maß
heightEnglish HeightGerman Körpergröße
weightEnglish WeightGerman Gewicht
fitEnglish How do you like it to fit?German Wie soll es sitzen?
slimEnglish CloseGerman Eng
regularEnglish RegularGerman Normal
relaxedEnglish RelaxedGerman Locker
yourSizeEnglish Your sizeGerman Deine Größe
choseEnglish People with your height and weight choseGerman Menschen mit deiner Größe und deinem Gewicht wählten
fitsBetweenEnglish You sit between two sizes. For a closer fit take the smaller one.German Du liegst zwischen zwei Größen. Für einen engeren Sitz nimm die kleinere.
useSizeEnglish Choose this sizeGerman Diese Größe wählen
chosenEnglish Size chosenGerman Größe gewählt
footLengthEnglish Foot lengthGerman Fußlänge
footHintEnglish Stand on a sheet of paper, mark heel and longest toe, measure the distance.German Auf ein Blatt Papier stellen, Ferse und längsten Zeh markieren, den Abstand messen.
shorterEnglish ShorterGerman Kürzer
longerEnglish LongerGerman Länger
guideEnglish Size guideGerman Größentabelle
chartEnglish Size chartGerman Größentabelle
howToEnglish How to measureGerman So misst du richtig
closeEnglish CloseGerman Schließen
pickSizeEnglish Pick your sizeGerman Wähle deine Größe
addToCartEnglish Add to cartGerman In den Warenkorb
stepEnglish StepGerman Schritt
ofEnglish ofGerman von
tipEnglish Measure over light clothing, with the tape snug but not tight.German Über leichter Kleidung messen, das Maßband anliegend, aber nicht stramm.

Accessibility

Read from the source of the component.

  • Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
  • Sets aria-checked, aria-controls, aria-expanded, aria-haspopup, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
  • Uses the roles img, radio, radiogroup and region.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 766 lines
components/ui/size-guide.tsx
'use client';

import { useId, useMemo, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';
import { Tabs } from '@/components/ui/tabs';
import { DialogMotionStyle, scrim, useModalMotion } from '@/components/ui/dialog';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';

/*
 * Size guides for fashion and shoe shops.
 *
 *   table           the size chart in cm or inches, with EU, US and UK sizes and a crosshair on hover
 *   fit-finder      height, weight and fit in, a recommended size and what similar people chose out
 *   how-to-measure  a drawn figure whose measuring lines light up step by step
 *   shoe-converter  foot length on a slider, the matching shoe sizes in every system
 *   dialog          the size picker of a product page with the guide in a dialog
 *
 * All measures come in centimetres, inches are calculated.
 */

export type SizeGuideVariant = 'table' | 'fit-finder' | 'how-to-measure' | 'shoe-converter' | 'dialog';
export type MeasureId = 'shoulder' | 'chest' | 'waist' | 'hips' | 'inseam' | 'sleeve';

export interface SizeChart {
  /** Column heads, e.g. ["XS", "S", "M", "L", "XL"] */
  sizes: string[];
  /** One row per measure, values in cm, one per size */
  rows: { id?: MeasureId; label: string; values: number[] }[];
  /** Size names in other systems, one per size */
  systems?: { label: string; values: string[] }[];
}

export interface FitSize {
  label: string;
  /** Height range in cm */
  height: [number, number];
  /** Weight range in kg */
  weight: [number, number];
}

export interface MeasureStep {
  id: MeasureId;
  title: string;
  text: string;
}

export interface ShoeRow {
  /** Foot length in cm */
  cm: number;
  eu: string;
  us: string;
  uk: string;
}

export interface SizeGuideProps {
  variant?: SizeGuideVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  chart?: SizeChart;
  /** fit-finder */
  fitSizes?: FitSize[];
  defaultHeight?: number;
  defaultWeight?: number;
  steps?: MeasureStep[];
  shoes?: ShoeRow[];
  defaultFoot?: number;
  /** dialog: the product the picker belongs to */
  product?: { name: string; variant?: string; image?: React.ReactNode };
  /** dialog: open on load (previews) */
  defaultOpen?: boolean;
  defaultUnit?: 'cm' | 'in';
  onPick?: (size: string) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    unit: 'Unit',
    size: 'Size',
    measure: 'Measure',
    height: 'Height',
    weight: 'Weight',
    fit: 'How do you like it to fit?',
    slim: 'Close',
    regular: 'Regular',
    relaxed: 'Relaxed',
    yourSize: 'Your size',
    chose: 'People with your height and weight chose',
    fitsBetween: 'You sit between two sizes. For a closer fit take the smaller one.',
    useSize: 'Choose this size',
    chosen: 'Size chosen',
    footLength: 'Foot length',
    footHint: 'Stand on a sheet of paper, mark heel and longest toe, measure the distance.',
    shorter: 'Shorter',
    longer: 'Longer',
    guide: 'Size guide',
    chart: 'Size chart',
    howTo: 'How to measure',
    close: 'Close',
    pickSize: 'Pick your size',
    addToCart: 'Add to cart',
    step: 'Step',
    of: 'of',
    tip: 'Measure over light clothing, with the tape snug but not tight.',
  },
  de: {
    unit: 'Einheit',
    size: 'Größe',
    measure: 'Maß',
    height: 'Körpergröße',
    weight: 'Gewicht',
    fit: 'Wie soll es sitzen?',
    slim: 'Eng',
    regular: 'Normal',
    relaxed: 'Locker',
    yourSize: 'Deine Größe',
    chose: 'Menschen mit deiner Größe und deinem Gewicht wählten',
    fitsBetween: 'Du liegst zwischen zwei Größen. Für einen engeren Sitz nimm die kleinere.',
    useSize: 'Diese Größe wählen',
    chosen: 'Größe gewählt',
    footLength: 'Fußlänge',
    footHint: 'Auf ein Blatt Papier stellen, Ferse und längsten Zeh markieren, den Abstand messen.',
    shorter: 'Kürzer',
    longer: 'Länger',
    guide: 'Größentabelle',
    chart: 'Größentabelle',
    howTo: 'So misst du richtig',
    close: 'Schließen',
    pickSize: 'Wähle deine Größe',
    addToCart: 'In den Warenkorb',
    step: 'Schritt',
    of: 'von',
    tip: 'Über leichter Kleidung messen, das Maßband anliegend, aber nicht stramm.',
  },
};

type Texts = (typeof TEXTS)['en'];

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';

function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: string; description?: string; aside?: React.ReactNode }) {
  if (!title) return null;
  return (
    <div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
      <div className="max-w-[40rem]">
        {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
        <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
        {description && (
          <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
            {description}
          </p>
        )}
      </div>
      {aside}
    </div>
  );
}

/** cm or inches as a segmented switch with a sliding thumb */
function UnitSwitch({ unit, onChange, t }: { unit: 'cm' | 'in'; onChange: (u: 'cm' | 'in') => void; t: Texts }) {
  return (
    <div role="radiogroup" aria-label={t.unit} className="relative inline-grid h-11 shrink-0 grid-cols-2 self-start sm:self-auto rounded-lg border border-border-strong bg-surface p-1">
      <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${unit === 'in' ? 100 : 0}%)` }} />
      {(['cm', 'in'] as const).map((u) => (
        <button key={u} type="button" role="radio" aria-checked={unit === u} onClick={() => onChange(u)} className={cn('relative z-10 w-14 rounded-md after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] text-[13.5px] font-medium transition-colors', unit === u ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', FOCUS)}>
          {u}
        </button>
      ))}
    </div>
  );
}

const asUnit = (locale: 'en' | 'de', cm: number, unit: 'cm' | 'in') => (unit === 'cm' ? formatNumber(locale, cm) : formatNumber(locale, round(cm / 2.54, 1), 1));

/* ------------------------------------------------------------------ */
/* The chart table, used by table and dialog                           */
/* ------------------------------------------------------------------ */

function ChartTable({ chart, unit, t, active, onActive }: { chart: SizeChart; unit: 'cm' | 'in'; t: Texts; active?: number; onActive?: (i: number | null) => void }) {
  const locale = useLocale();
  const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
  const col = hover?.c ?? active ?? -1;
  // Phones get their own layout: pick a size, read its measures as a list
  const [pick, setPick] = useState(Math.max(0, active ?? Math.floor(chart.sizes.length / 2)));
  const name = useId();
  const phone = (
    <div className="sm:hidden">
      <div role="radiogroup" aria-label={t.size} className="grid border-b border-border bg-surface p-1.5" style={{ gridTemplateColumns: `repeat(${chart.sizes.length}, minmax(0, 1fr))` }}>
        {chart.sizes.map((sz, c) => (
          <label key={sz} className={cn('grid h-11 cursor-pointer place-items-center rounded-lg text-[14px] font-semibold transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', c === pick ? 'bg-foreground text-background' : 'text-muted-foreground')}>
            <input type="radio" name={name} checked={c === pick} onChange={() => setPick(c)} className="sr-only" />
            {sz}
          </label>
        ))}
      </div>
      <dl key={`${pick}-${unit}`} className="divide-y divide-border">
        {[...(chart.systems ?? []).map((sys) => ({ label: sys.label, value: sys.values[pick], unit: '' })), ...chart.rows.map((row) => ({ label: row.label, value: asUnit(locale, row.values[pick], unit), unit }))].map((x, i) => (
          <div key={x.label} className="flex animate-[kitshop-in_0.4s_var(--ease-kit)_both] items-baseline justify-between px-4 py-3 text-[14px]" style={{ animationDelay: `${i * 35}ms` }}>
            <dt className="text-muted-foreground">{x.label}</dt>
            <dd className="font-semibold tabular-nums text-foreground">
              {x.value} <span className="font-normal text-subtle-foreground">{x.unit}</span>
            </dd>
          </div>
        ))}
      </dl>
    </div>
  );
  return (
    <>
    {phone}
    <div className="hidden sm:block" role="region" aria-label={t.chart}>
      <table className="w-full border-separate border-spacing-0 text-[14px]" onPointerLeave={() => { setHover(null); onActive?.(null); }}>
        <thead>
          <tr>
            <th scope="col" className="sticky left-0 z-10 rounded-tl-xl border-b border-border bg-surface px-4 py-3 text-left text-[12.5px] font-medium text-muted-foreground">
              {t.size}
            </th>
            {chart.sizes.map((s, c) => (
              <th key={s} scope="col" className={cn('border-b border-border px-3 py-3 text-center text-[14px] font-semibold transition-colors duration-200', c === chart.sizes.length - 1 && 'rounded-tr-xl', c === col ? 'bg-foreground text-background' : 'bg-surface text-foreground')}>
                {s}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {chart.systems?.map((sys) => (
            <tr key={sys.label}>
              <th scope="row" className="sticky left-0 z-10 border-b border-border bg-card px-4 py-3 text-left text-[13px] font-medium text-muted-foreground">
                {sys.label}
              </th>
              {sys.values.map((v, c) => (
                <td key={c} className={cn('border-b border-border px-3 py-3 text-center tabular-nums text-muted-foreground transition-colors duration-200', c === col && 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-foreground')}>
                  {v}
                </td>
              ))}
            </tr>
          ))}
          {chart.rows.map((row, r) => (
            <tr key={row.label}>
              <th scope="row" className={cn('sticky left-0 z-10 border-b border-border px-4 py-3 text-left font-medium transition-colors duration-200', hover?.r === r ? 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-foreground' : 'bg-card text-foreground')}>
                {row.label} <span className="font-normal text-subtle-foreground">{unit}</span>
              </th>
              {row.values.map((v, c) => (
                <td
                  key={c}
                  onPointerEnter={() => { setHover({ r, c }); onActive?.(c); }}
                  onClick={() => setHover({ r, c })}
                  className={cn(
                    'border-b border-border px-3 py-3 text-center tabular-nums transition-colors duration-200',
                    hover?.r === r && hover?.c === c ? 'bg-primary font-semibold text-primary-foreground' : c === col || hover?.r === r ? 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-foreground' : 'text-muted-foreground',
                  )}
                >
                  <span key={unit} className="inline-block animate-[kitshop-swap_0.35s_var(--ease-kit)]">
                    {asUnit(locale, v, unit)}
                  </span>
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
    </>
  );
}

function TableVariant(p: SizeGuideProps & { t: Texts }) {
  const { t } = p;
  const [unit, setUnit] = useState<'cm' | 'in'>(p.defaultUnit ?? 'cm');
  const chart = p.chart ?? { sizes: [], rows: [] };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} aside={<UnitSwitch unit={unit} onChange={setUnit} t={t} />} />
      <Reveal effect="rise" className={cn(CARD, 'overflow-hidden')}>
        <ChartTable chart={chart} unit={unit} t={t} />
      </Reveal>
      <p className="mt-5 flex items-start gap-2 text-[13.5px] text-muted-foreground">
        <Icon name="ruler" size={15} className="mt-0.5 shrink-0" />
        {t.tip}
      </p>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* fit-finder                                                          */
/* ------------------------------------------------------------------ */

function RangeField({ label, value, min, max, step, unit, onChange }: { label: string; value: number; min: number; max: number; step: number; unit: string; onChange: (v: number) => void }) {
  const id = useId();
  const locale = useLocale();
  const pct = round(((value - min) / (max - min)) * 100, 2);
  return (
    <div>
      <div className="flex items-baseline justify-between">
        <label htmlFor={id} className="text-[14px] font-medium text-foreground">
          {label}
        </label>
        <output htmlFor={id} className="text-[20px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
          {formatNumber(locale, value)} <span className="text-[13px] font-normal text-muted-foreground">{unit}</span>
        </output>
      </div>
      <div className="relative mt-3 h-11">
        <span aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
        <span aria-hidden className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
        <span aria-hidden className="pointer-events-none absolute top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-[var(--shadow-md)]" style={{ left: `${pct}%` }} />
        <input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} className="peer absolute inset-0 size-full cursor-pointer opacity-0" />
      </div>
    </div>
  );
}

function FitFinder(p: SizeGuideProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const sizes = p.fitSizes ?? [];
  const [height, setHeight] = useState(p.defaultHeight ?? 176);
  const [weight, setWeight] = useState(p.defaultWeight ?? 72);
  const [pref, setPref] = useState<-1 | 0 | 1>(0);
  const [picked, setPicked] = useState<string | null>(null);
  const name = useId();
  // A soft score per size: how far height and weight sit from the middle of its range
  const scores = useMemo(() => {
    const raw = sizes.map((s) => {
      const hm = (s.height[0] + s.height[1]) / 2;
      const wm = (s.weight[0] + s.weight[1]) / 2;
      const hd = (height - hm) / Math.max(1, s.height[1] - s.height[0]);
      const wd = (weight - wm) / Math.max(1, s.weight[1] - s.weight[0]);
      return Math.exp(-(hd * hd + wd * wd) * 1.4);
    });
    const sum = raw.reduce((a, b) => a + b, 0) || 1;
    return raw.map((v) => v / sum);
  }, [sizes, height, weight]);
  const best = scores.indexOf(Math.max(...scores));
  const rec = Math.max(0, Math.min(sizes.length - 1, best + pref));
  const sorted = [...scores].sort((a, b) => b - a);
  const between = sorted.length > 1 && sorted[1] > sorted[0] * 0.72;
  const [ref, inView] = useInView<HTMLDivElement>();
  const recLabel = sizes[rec]?.label ?? '';
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-10">
        <Reveal effect="rise" className={cn(CARD, 'space-y-7 p-6 sm:p-8')}>
          <RangeField label={t.height} value={height} min={150} max={205} step={1} unit="cm" onChange={(v) => { setHeight(v); setPicked(null); }} />
          <RangeField label={t.weight} value={weight} min={45} max={120} step={1} unit="kg" onChange={(v) => { setWeight(v); setPicked(null); }} />
          <fieldset>
            <legend className="mb-3 text-[14px] font-medium text-foreground">{t.fit}</legend>
            <div className="relative grid grid-cols-3 rounded-lg border border-border-strong bg-surface p-1">
              <span aria-hidden className="absolute inset-y-1 left-1 w-[calc((100%-0.5rem)/3)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${(pref + 1) * 100}%)` }} />
              {([-1, 0, 1] as const).map((v) => (
                <label key={v} className={cn('relative z-10 grid min-h-11 cursor-pointer place-items-center rounded-md text-[13.5px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', pref === v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
                  <input type="radio" name={name} checked={pref === v} onChange={() => { setPref(v); setPicked(null); }} className="sr-only" />
                  {v === -1 ? t.slim : v === 0 ? t.regular : t.relaxed}
                </label>
              ))}
            </div>
          </fieldset>
        </Reveal>

        <div ref={ref} className={cn(CARD, 'flex flex-col p-6 sm:p-8')} aria-live="polite">
          <p className="text-[14px] text-muted-foreground">{t.yourSize}</p>
          <div className="mt-2 flex items-end gap-4">
            <Swap value={recLabel}>
              <span className="text-[72px] font-semibold leading-[0.9] tracking-[-0.05em] text-foreground">{recLabel}</span>
            </Swap>
            {between && <p className="mb-1 max-w-[16rem] text-pretty text-[13px] leading-snug text-muted-foreground">{t.fitsBetween}</p>}
          </div>
          <p className="mt-6 text-[13px] text-muted-foreground">{t.chose}</p>
          <ul className="mt-3 space-y-2">
            {sizes.map((s, i) => {
              const share = scores[i] ?? 0;
              return (
                <li key={s.label} className="grid grid-cols-[2.5rem_minmax(0,1fr)_3rem] items-center gap-3 text-[13px]">
                  <span className={cn('font-semibold tabular-nums', i === rec ? 'text-foreground' : 'text-muted-foreground')}>{s.label}</span>
                  <span className="relative h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]" aria-hidden>
                    <span
                      className={cn('absolute inset-0 origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', i === rec ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_35%,transparent)]')}
                      style={{ transform: `scaleX(${inView ? round(share, 3) : 0})`, transitionDelay: `${i * 50}ms` }}
                    />
                  </span>
                  <span className="text-right tabular-nums text-muted-foreground">{locale === 'de' ? `${Math.round(share * 100)} %` : `${Math.round(share * 100)}%`}</span>
                </li>
              );
            })}
          </ul>
          <span aria-hidden className="block min-h-7 flex-1" />
          <Button
            className="w-full"
            size="lg"
            iconLeft={picked === recLabel ? 'check-circle' : 'check'}
            onClick={() => {
              setPicked(recLabel);
              p.onPick?.(recLabel);
            }}
          >
            <Swap value={picked === recLabel}>{picked === recLabel ? `${t.chosen} · ${recLabel}` : `${t.useSize} · ${recLabel}`}</Swap>
          </Button>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The figure for how-to-measure and the dialog                        */
/* ------------------------------------------------------------------ */

const LINES: Record<MeasureId, { d: string; label: [number, number] }> = {
  shoulder: { d: 'M62 84 C80 78 120 78 138 84', label: [100, 79] },
  chest: { d: 'M55 112 H145', label: [100, 112] },
  waist: { d: 'M61 166 H139', label: [100, 166] },
  hips: { d: 'M60 210 H140', label: [100, 210] },
  inseam: { d: 'M100 236 V384', label: [100, 306] },
  sleeve: { d: 'M140 86 C152 96 158 140 160 186 L163 234', label: [158.5, 160] },
};

function Figure({ active, steps, onPick }: { active: MeasureId; steps: MeasureStep[]; onPick?: (id: MeasureId) => void }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  const ids = steps.map((s) => s.id);
  return (
    <div ref={ref} className="relative mx-auto w-full max-w-[12rem] sm:max-w-[17rem]">
      <svg viewBox="20 8 180 392" className="h-auto w-full" role="img" aria-hidden>
        <g className="fill-[color-mix(in_oklab,var(--foreground)_9%,var(--card))] stroke-[color-mix(in_oklab,var(--foreground)_22%,var(--card))]" strokeWidth="1.2">
          <circle cx="100" cy="36" r="19" />
          <rect x="92" y="54" width="16" height="22" rx="6" />
          <path d="M54 92 C44 100 40 140 38 180 L36 236 L46 236 L52 182 C54 150 56 120 58 110 Z" />
          <path d="M146 92 C156 100 160 140 162 180 L164 236 L154 236 L148 182 C146 150 144 120 142 110 Z" />
          <path d="M62 84 C80 76 120 76 138 84 C146 88 148 96 146 110 L140 160 C138 175 134 185 136 200 L140 228 L60 228 L64 200 C66 185 62 175 60 160 L54 110 C52 96 54 88 62 84 Z" />
          <path d="M60 228 L98 228 L95 388 L76 388 Z" />
          <path d="M102 228 L140 228 L124 388 L105 388 Z" />
        </g>
        {ids.map((id, i) => {
          const on = id === active;
          const line = LINES[id];
          return (
            <g key={id} className={cn('transition-opacity duration-500', on ? 'opacity-100' : 'opacity-35')}>
              <path
                d={line.d}
                fill="none"
                strokeWidth={on ? 2.4 : 1.6}
                strokeLinecap="round"
                pathLength={1}
                strokeDasharray="1"
                strokeDashoffset={inView ? 0 : 1}
                className={cn('transition-[stroke-dashoffset,stroke-width] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'stroke-primary' : 'stroke-foreground')}
                style={{ transitionDelay: inView ? `${i * 140}ms` : '0ms' }}
              />
              <circle cx={line.label[0]} cy={line.label[1]} r="9" className={cn('transition-colors duration-300', on ? 'fill-primary' : 'fill-card stroke-border-strong')} strokeWidth="1" />
              <text x={line.label[0]} y={line.label[1] + 3.6} textAnchor="middle" className={cn('text-[10px] font-semibold', on ? 'fill-primary-foreground' : 'fill-foreground')}>
                {i + 1}
              </text>
            </g>
          );
        })}
      </svg>
      {onPick && (
        <div className="absolute inset-0">
          {ids.map((id, i) => {
            const [x, y] = LINES[id].label;
            return (
              <button
                key={id}
                type="button"
                tabIndex={-1}
                aria-hidden
                onClick={() => onPick(id)}
                className="absolute size-11 -translate-x-1/2 -translate-y-1/2 rounded-full"
                style={{ left: `${round(((x - 20) / 180) * 100, 2)}%`, top: `${round(((y - 8) / 392) * 100, 2)}%` }}
                title={String(i + 1)}
              />
            );
          })}
        </div>
      )}
    </div>
  );
}

function MeasureSteps({ steps, active, onPick, t }: { steps: MeasureStep[]; active: MeasureId; onPick: (id: MeasureId) => void; t: Texts }) {
  return (
    <ol className="space-y-2">
      {steps.map((s, i) => {
        const on = s.id === active;
        return (
          <li key={s.id}>
            <button
              type="button"
              aria-expanded={on}
              onClick={() => onPick(s.id)}
              onPointerEnter={(e) => e.pointerType === 'mouse' && onPick(s.id)}
              className={cn('flex w-full gap-4 rounded-xl border p-4 text-left transition-[background-color,border-color] duration-300', on ? 'border-border-strong bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'border-transparent hover:bg-surface', FOCUS)}
            >
              <span className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[12.5px] font-semibold tabular-nums transition-colors duration-300', on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
              <span className="min-w-0">
                <span className="block text-[15px] font-semibold text-foreground">{s.title}</span>
                <span className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                  <span className="overflow-hidden">
                    <span className="block pt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</span>
                  </span>
                </span>
              </span>
            </button>
          </li>
        );
      })}
    </ol>
  );
}

function HowToMeasure(p: SizeGuideProps & { t: Texts }) {
  const { t } = p;
  const steps = p.steps ?? [];
  const [active, setActive] = useState<MeasureId>(steps[0]?.id ?? 'chest');
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)] md:items-start lg:gap-16">
        <Reveal effect="scale" className={cn(CARD, 'bg-[radial-gradient(70%_60%_at_50%_30%,color-mix(in_oklab,var(--primary)_7%,transparent),transparent)] px-6 py-8 md:sticky md:top-8')}>
          <Figure active={active} steps={steps} onPick={setActive} />
        </Reveal>
        <div className="md:self-center">
          <MeasureSteps steps={steps} active={active} onPick={setActive} t={t} />
          <p className="mt-6 flex items-start gap-2 ps-4 text-[13.5px] text-muted-foreground">
            <Icon name="lightbulb" size={15} className="mt-0.5 shrink-0" />
            {t.tip}
          </p>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* shoe-converter                                                      */
/* ------------------------------------------------------------------ */

function ShoeConverter(p: SizeGuideProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const rows = p.shoes ?? [];
  const min = rows[0]?.cm ?? 22;
  const max = rows[rows.length - 1]?.cm ?? 30;
  const [foot, setFoot] = useState(p.defaultFoot ?? 26.5);
  // The row whose foot length is the first one that fits the foot
  const idx = Math.max(0, rows.findIndex((r) => r.cm >= foot - 0.001));
  const row = rows[idx];
  const id = useId();
  const pct = round(((foot - min) / Math.max(0.1, max - min)) * 100, 2);
  const scale = round(0.82 + ((foot - min) / Math.max(0.1, max - min)) * 0.18, 3);
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start lg:gap-10">
        <Reveal effect="rise" className={cn(CARD, 'grid grid-cols-[6.5rem_minmax(0,1fr)] gap-6 p-6 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-8 sm:p-8')}>
          {/* A footprint with a measuring line from heel to toe, both grow with the length */}
          <div className="relative grid h-[15rem] place-items-end justify-center self-center sm:h-[17rem]" aria-hidden>
            <svg viewBox="-4 0 96 176" className="h-full w-auto overflow-visible">
              <g className="origin-bottom transition-transform duration-500 ease-[var(--ease-kit)] [transform-box:fill-box] motion-reduce:transition-none" style={{ transform: `scale(${scale})` }}>
                <g className="fill-[color-mix(in_oklab,var(--primary)_16%,var(--card))] stroke-[color-mix(in_oklab,var(--primary)_62%,var(--foreground))]" strokeWidth="1.4">
                  <path d="M35 30 C50 30 58 40 60 56 C62 76 63 98 62 118 C60 140 56 154 48 162 C43 167 38 168 33 168 C25 168 19 163 16 153 C12 138 14 120 16 104 C18 86 12 70 11 54 C10 38 20 30 35 30 Z" />
                  <ellipse cx="23" cy="17" rx="7" ry="9" />
                  <ellipse cx="36" cy="12" rx="5" ry="6.5" />
                  <ellipse cx="46.5" cy="14" rx="4.5" ry="5.5" />
                  <ellipse cx="55" cy="20" rx="4" ry="5" />
                  <ellipse cx="61.5" cy="28.5" rx="3.4" ry="4.4" />
                </g>
                <g className="stroke-foreground" strokeWidth="1.2" fill="none" strokeLinecap="round">
                  <path d="M80 9 V168" />
                  <path d="M76 13 L80 9 L84 13 M76 164 L80 168 L84 164 M74 9 H86 M74 168 H86" />
                </g>
              </g>
            </svg>
          </div>
          <div className="flex flex-col">
            <label htmlFor={id} className="text-[14px] font-medium text-foreground">
              {t.footLength}
            </label>
            <output htmlFor={id} className="mt-1 text-[44px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground">
              {formatNumber(locale, foot, 1)} <span className="text-[18px] font-normal tracking-normal text-muted-foreground">cm</span>
            </output>
            <div className="relative mt-6 h-11">
              <span aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
              <span aria-hidden className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
              <span aria-hidden className="pointer-events-none absolute top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-[var(--shadow-md)]" style={{ left: `${pct}%` }} />
              <input id={id} type="range" min={min} max={max} step={0.5} value={foot} onChange={(e) => setFoot(Number(e.target.value))} className="absolute inset-0 size-full cursor-pointer opacity-0" />
            </div>
            <div className="mt-2 flex justify-between text-[12px] text-muted-foreground" aria-hidden>
              <span>{t.shorter}</span>
              <span>{t.longer}</span>
            </div>
            <p className="mt-6 text-pretty text-[13px] leading-relaxed text-muted-foreground">{t.footHint}</p>
          </div>
        </Reveal>

        <div className="grid grid-cols-1 gap-4">
          <div className="grid grid-cols-3 gap-px overflow-hidden rounded-2xl border border-border bg-border" aria-live="polite">
            {(['eu', 'us', 'uk'] as const).map((k) => (
              <div key={k} className="bg-card p-5 text-center">
                <p className="text-[12.5px] font-medium uppercase tracking-[0.06em] text-muted-foreground">{k}</p>
                <Swap value={row?.[k] ?? ''}>
                  <span className="mt-1 block text-[34px] font-semibold tracking-[-0.03em] tabular-nums text-foreground">{row?.[k]}</span>
                </Swap>
              </div>
            ))}
          </div>
          <div className={cn(CARD, 'overflow-hidden')}>
            <table className="w-full text-center text-[13.5px]">
              <thead className="bg-surface text-[12px] text-muted-foreground">
                <tr>
                  <th scope="col" className="py-2.5 font-medium">cm</th>
                  <th scope="col" className="py-2.5 font-medium">EU</th>
                  <th scope="col" className="py-2.5 font-medium">US</th>
                  <th scope="col" className="py-2.5 font-medium">UK</th>
                </tr>
              </thead>
              <tbody>
                {rows.map((r, i) => (
                  <tr key={r.cm} className={cn('border-t border-border transition-colors duration-300', i === idx ? 'bg-foreground text-background' : 'text-muted-foreground')}>
                    <td className="py-2 tabular-nums">{formatNumber(locale, r.cm, 1)}</td>
                    <td className="py-2 tabular-nums">{r.eu}</td>
                    <td className="py-2 tabular-nums">{r.us}</td>
                    <td className="py-2 tabular-nums">{r.uk}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* dialog                                                              */
/* ------------------------------------------------------------------ */

function DialogVariant(p: SizeGuideProps & { t: Texts }) {
  const { t } = p;
  const chart = p.chart ?? { sizes: [], rows: [] };
  const steps = p.steps ?? [];
  const [open, setOpen] = useState(!!p.defaultOpen);
  const [modal, present] = useModalMotion(open, setOpen, { motion: 'bottom' });
  const [unit, setUnit] = useState<'cm' | 'in'>(p.defaultUnit ?? 'cm');
  const [size, setSize] = useState(chart.sizes[Math.floor(chart.sizes.length / 2)] ?? '');
  const [active, setActive] = useState<MeasureId>(steps[0]?.id ?? 'chest');
  const id = useId();
  const sizeIndex = chart.sizes.indexOf(size);
  return (
    <Section className={p.className}>
      <ShopMotion />
      <DialogMotionStyle />
      <div className={cn('mx-auto grid grid-cols-1 items-center gap-8', p.product?.image ? 'max-w-[60rem] md:grid-cols-2 md:gap-12' : 'max-w-[30rem]')}>
      {p.product?.image && (
        <Reveal effect="scale" className="overflow-hidden rounded-2xl bg-surface-2 [&>*]:aspect-[4/3] [&>*]:size-full md:[&>*]:aspect-square">
          {p.product.image}
        </Reveal>
      )}
      <div className={cn(CARD, 'p-6 sm:p-7')}>
        {p.eyebrow && <Eyebrow className="mb-2">{p.eyebrow}</Eyebrow>}
        <h2 className="text-[22px] font-semibold tracking-[-0.02em] text-foreground">{p.product?.name ?? p.title}</h2>
        {p.product?.variant && <p className="mt-1 text-[14px] text-muted-foreground">{p.product.variant}</p>}
        <fieldset className="mt-6">
          <legend className="mb-3 flex w-full items-baseline justify-between text-[14px] font-medium text-foreground">
            {t.pickSize}
            <button type="button" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-controls={id} className={cn('inline-flex min-h-11 items-center gap-1.5 rounded-md text-[13.5px] font-medium text-foreground underline decoration-border-strong underline-offset-4 hover:decoration-foreground', FOCUS)}>
              <Icon name="ruler" size={15} />
              {t.guide}
            </button>
          </legend>
          <div role="radiogroup" aria-label={t.size} className="grid grid-cols-5 gap-2">
            {chart.sizes.map((s) => (
              <label key={s} className={cn('grid h-11 cursor-pointer place-items-center rounded-lg border text-[14px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', s === size ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-background text-foreground hover:bg-accent')}>
                <input type="radio" name={`${id}-size`} checked={s === size} onChange={() => setSize(s)} className="sr-only" />
                {s}
              </label>
            ))}
          </div>
        </fieldset>
        <Button size="lg" className="mt-6 w-full" iconLeft="bag">
          {t.addToCart}
        </Button>
      </div>
      </div>

      <dialog
        {...modal}
        id={id}
        aria-labelledby={`${id}-t`}
        className={cn('kitfx-dialog m-auto w-full max-w-none overflow-visible bg-transparent p-3 text-left outline-none data-[closing]:pointer-events-none max-sm:mb-0 max-sm:p-0 sm:w-[calc(100%-2rem)] sm:max-w-[48rem]', scrim)}
      >
        {present && (
          <div className="relative flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden rounded-2xl border border-border bg-popover shadow-[var(--highlight),var(--shadow-lg)] max-sm:max-h-[88dvh] max-sm:rounded-b-none">
            <div className="kitfx-dialog-part flex items-start justify-between gap-4 border-b border-border px-6 py-4">
              <div>
                <h2 id={`${id}-t`} className="text-[17px] font-semibold text-foreground">
                  {t.guide}
                </h2>
                {p.product && <p className="text-[13px] text-muted-foreground">{p.product.name}</p>}
              </div>
              <div className="flex items-center gap-2">
                <UnitSwitch unit={unit} onChange={setUnit} t={t} />
                <button type="button" onClick={() => setOpen(false)} aria-label={t.close} className={cn('group/close grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}>
                  <Icon name="x" size={18} className="transition-transform duration-300 group-hover/close:rotate-90" />
                </button>
              </div>
            </div>
            <div className="kitfx-dialog-part min-h-0 flex-1 overflow-y-auto px-6 py-5">
              <Tabs
                label={t.guide}
                variant="underline"
                items={[
                  { value: 'chart', label: t.chart, content: <div className="overflow-hidden rounded-xl border border-border"><ChartTable chart={chart} unit={unit} t={t} active={sizeIndex} /></div> },
                  {
                    value: 'measure',
                    label: t.howTo,
                    content: (
                      <div className="grid grid-cols-1 gap-6 sm:grid-cols-[12rem_minmax(0,1fr)] sm:items-start">
                        <div className="mx-auto w-40 sm:w-full">
                          <Figure active={active} steps={steps} onPick={setActive} />
                        </div>
                        <MeasureSteps steps={steps} active={active} onPick={setActive} t={t} />
                      </div>
                    ),
                  },
                ]}
                panelClassName="pt-4"
              />
            </div>
          </div>
        )}
      </dialog>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * Size guides in five layouts. table: the chart in cm or inches. fit-finder:
 * a recommended size. how-to-measure: a figure with measuring lines.
 * shoe-converter: foot length to shoe sizes. dialog: the guide from the size picker.
 */
export function SizeGuide(props: SizeGuideProps) {
  const { variant = 'table' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'fit-finder') return <FitFinder {...props} t={t} />;
  if (variant === 'how-to-measure') return <HowToMeasure {...props} t={t} />;
  if (variant === 'shoe-converter') return <ShoeConverter {...props} t={t} />;
  if (variant === 'dialog') return <DialogVariant {...props} t={t} />;
  return <TableVariant {...props} t={t} />;
}

Frequent questions

How do I install Size guide?

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

Which variants does Size guide have?

5 variants, Size chart, Fit finder, How to measure, Shoe size converter and Guide from the size picker. You pick one with the variant prop, for example variant="fit-finder".

Can I translate the texts of Size guide?

Yes. Size guide carries 27 texts of its own in English and German, such as “Unit”, “Size” and “Measure”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Size guide?

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

Can I adjust Size guide without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching SizeGuide code to copy.

Often searched

Jump to

Copied to the clipboard