Skip to content
UI Kit
Accent color

Shipping estimator

When it arrives and what it costs. Options for a postcode, the cut-off of today with the way to the door, rates per region and a delivery window to book.

One of 4 components in Delivery and stores

4 variants

Variants

Postcode check

Country and postcode in, delivery options with dates and prices out. A wrong postcode shakes and says what fits, the check spins briefly, then the options run in and their dates come into focus.

Open in Studio

Order cut-off

The time left until today’s cut-off in a ring that empties, the arrival day next to it. Standard or faster swaps the date with a short rise, and the way to the door fills its line while scrolling.

Open in Studio

Rates by region

Shipping rates per region in tabs with a sliding thumb. A table with time, cost and free shipping threshold on wide screens, cards on phones, rows run in on every switch. Promises below rise in turn.

Open in Studio

Delivery window

A strip of days with the lowest price per day and a marker that glides to the chosen one, arrow keys move it too. Time windows run in per day, full ones stay visible and struck through, the summary beside books the window.

Open in Studio

Details

What Shipping estimator brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/shipping-estimator.tsx
Brings along
utilskitbuttoniconrevealmotionsectiontabsselectproduct-image
npm packages
None
Languages
English and German, 31 texts of its own
In collections
Online shop

Props

The ShippingEstimatorProps interface from components/ui/shipping-estimator.tsx, with the comments from the code.

  • variantoptional
    ShippingEstimatorVariant

    postcodecountdownrates-tabledelivery-slots

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • countriesoptional
    { code: string; label: string; pattern?: string; example?: string }[]

    postcode: countries to pick from

  • defaultCountryoptional
    string
  • defaultPostcodeoptional
    string
  • defaultCheckedoptional
    boolean

    postcode: show the options right away (previews)

  • placeoptional
    string

    postcode: the place the postcode resolves to, e.g. "Hamburg"

  • optionsoptional
    ShippingOption[]
  • minutesLeftoptional
    number

    countdown: minutes until the cut-off of today

  • arrivaloptional
    { standard: string; fast?: string; fastName?: string; fastPrice?: number; standardName?: string }

    countdown: arrival with the standard and the fast method

  • stepsoptional
    ShippingStep[]
  • zonesoptional
    ShippingZone[]
  • notesoptional
    string[]

    rates-table: lines below the table, e.g. customs and islands

  • daysoptional
    DeliveryDay[]
  • defaultDayoptional
    string
  • defaultSlotoptional
    string
  • freeFromoptional
    number

    Free shipping from this order value on

  • currencyoptional
    string
  • onCheckoptional
    (country: string, postcode: string) => void
  • onSelectoptional
    (day: DeliveryDay, slotId: string) => void

Built-in texts

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

Show all 31 texts
countryEnglish CountryGerman Land
postcodeEnglish PostcodeGerman Postleitzahl
checkEnglish Show delivery datesGerman Lieferzeiten anzeigen
checkingEnglish CheckingGerman Wird geprüft
changeEnglish ChangeGerman Ändern
deliveringToEnglish Delivering toGerman Lieferung nach
invalidEnglish Enter a postcode likeGerman Gib eine Postleitzahl ein wie
freeEnglish FreeGerman Kostenlos
arrivesEnglish ArrivesGerman Ankunft
orderWithinEnglish Order withinGerman Bestell in den nächsten
andGetEnglish and it arrivesGerman und es kommt
hEnglish hGerman Std.
minEnglish minGerman Min.
cutoffPassedEnglish Ordered now, it ships tomorrow and arrivesGerman Heute bestellt, geht es morgen raus und kommt
standardEnglish StandardGerman Standard
fasterEnglish FasterGerman Schneller
methodEnglish Shipping methodGerman Versandart
timeEnglish Delivery timeGerman Lieferzeit
costEnglish CostGerman Kosten
freeFromEnglish Free fromGerman Kostenlos ab
regionEnglish RegionGerman Region
pickDayEnglish Pick a dayGerman Wähle einen Tag
pickSlotEnglish Pick a time windowGerman Wähle ein Zeitfenster
fullEnglish FullGerman Voll
yourSlotEnglish Your deliveryGerman Deine Lieferung
confirmEnglish Book this windowGerman Zeitfenster buchen
confirmedEnglish Window bookedGerman Zeitfenster gebucht
earlierEnglish Earlier daysGerman Frühere Tage
laterEnglish Later daysGerman Spätere Tage
optionsEnglish Delivery optionsGerman Lieferoptionen
datePendingEnglish Date after the checkGerman Datum nach der Prüfung

Accessibility

Read from the source of the component.

  • Shipping estimator handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-busy, aria-checked, aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the roles alert, radio and radiogroup.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • 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. 3 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, 742 lines
components/ui/shipping-estimator.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { Tabs } from '@/components/ui/tabs';
import { NativeSelect } from '@/components/ui/select';
import { ShopMotion, Swap, formatPrice } from '@/components/ui/product-image';

/*
 * When will it arrive and what does it cost. For product pages, the cart and
 * the shipping page.
 *
 *   postcode        country and postcode in, delivery options with dates and prices out
 *   countdown       "order within 2 h 14 min, it arrives Thursday", with the way there as a timeline
 *   rates-table     shipping rates per region in tabs, as a table on wide screens and cards on phones
 *   delivery-slots  pick a day and a time window, with prices per window and full windows marked
 */

export type ShippingEstimatorVariant = 'postcode' | 'countdown' | 'rates-table' | 'delivery-slots';

export interface ShippingOption {
  id: string;
  name: string;
  /** e.g. "Delivered by Parcel North" or "Pick up at Nordhaus Hamburg" */
  note?: string;
  /** Already formatted arrival, e.g. "Thu, Oct 24" */
  eta: string;
  /** Short line above the date, e.g. "Tomorrow" */
  etaNote?: string;
  /** 0 shows "Free" */
  price: number;
  icon?: IconName;
  /** A small label, e.g. "Fastest" */
  tag?: string;
}

export interface ShippingStep {
  label: string;
  /** Already formatted, e.g. "Today" or "Thu, Oct 24" */
  date: string;
  /** Date with the faster method, for countdown */
  fastDate?: string;
  icon?: IconName;
}

export interface ShippingZone {
  id: string;
  label: string;
  rows: { method: string; time: string; price: number; freeFrom?: number; note?: string }[];
  note?: string;
}

export interface DeliveryDay {
  id: string;
  /** e.g. "Thu" */
  weekday: string;
  /** e.g. "24" */
  day: string;
  /** e.g. "Oct" */
  month?: string;
  /** Full date for screen readers and the summary, e.g. "Thursday, October 24" */
  label: string;
  slots: { id: string; label: string; price: number; full?: boolean; tag?: string }[];
}

export interface ShippingEstimatorProps {
  variant?: ShippingEstimatorVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** postcode: countries to pick from */
  countries?: { code: string; label: string; pattern?: string; example?: string }[];
  defaultCountry?: string;
  defaultPostcode?: string;
  /** postcode: show the options right away (previews) */
  defaultChecked?: boolean;
  /** postcode: the place the postcode resolves to, e.g. "Hamburg" */
  place?: string;
  options?: ShippingOption[];
  /** countdown: minutes until the cut-off of today */
  minutesLeft?: number;
  /** countdown: arrival with the standard and the fast method */
  arrival?: { standard: string; fast?: string; fastName?: string; fastPrice?: number; standardName?: string };
  steps?: ShippingStep[];
  zones?: ShippingZone[];
  /** rates-table: lines below the table, e.g. customs and islands */
  notes?: string[];
  days?: DeliveryDay[];
  defaultDay?: string;
  defaultSlot?: string;
  /** Free shipping from this order value on */
  freeFrom?: number;
  currency?: string;
  onCheck?: (country: string, postcode: string) => void;
  onSelect?: (day: DeliveryDay, slotId: string) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    country: 'Country',
    postcode: 'Postcode',
    check: 'Show delivery dates',
    checking: 'Checking',
    change: 'Change',
    deliveringTo: 'Delivering to',
    invalid: 'Enter a postcode like',
    free: 'Free',
    arrives: 'Arrives',
    orderWithin: 'Order within',
    andGet: 'and it arrives',
    h: 'h',
    min: 'min',
    cutoffPassed: 'Ordered now, it ships tomorrow and arrives',
    standard: 'Standard',
    faster: 'Faster',
    method: 'Shipping method',
    time: 'Delivery time',
    cost: 'Cost',
    freeFrom: 'Free from',
    region: 'Region',
    pickDay: 'Pick a day',
    pickSlot: 'Pick a time window',
    full: 'Full',
    yourSlot: 'Your delivery',
    confirm: 'Book this window',
    confirmed: 'Window booked',
    earlier: 'Earlier days',
    later: 'Later days',
    options: 'Delivery options',
    datePending: 'Date after the check',
  },
  de: {
    country: 'Land',
    postcode: 'Postleitzahl',
    check: 'Lieferzeiten anzeigen',
    checking: 'Wird geprüft',
    change: 'Ändern',
    deliveringTo: 'Lieferung nach',
    invalid: 'Gib eine Postleitzahl ein wie',
    free: 'Kostenlos',
    arrives: 'Ankunft',
    orderWithin: 'Bestell in den nächsten',
    andGet: 'und es kommt',
    h: 'Std.',
    min: 'Min.',
    cutoffPassed: 'Heute bestellt, geht es morgen raus und kommt',
    standard: 'Standard',
    faster: 'Schneller',
    method: 'Versandart',
    time: 'Lieferzeit',
    cost: 'Kosten',
    freeFrom: 'Kostenlos ab',
    region: 'Region',
    pickDay: 'Wähle einen Tag',
    pickSlot: 'Wähle ein Zeitfenster',
    full: 'Voll',
    yourSlot: 'Deine Lieferung',
    confirm: 'Zeitfenster buchen',
    confirmed: 'Zeitfenster gebucht',
    earlier: 'Frühere Tage',
    later: 'Spätere Tage',
    options: 'Lieferoptionen',
    datePending: 'Datum nach der Prüfung',
  },
};

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)]';
const ACCENT_SOFT = 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]';

function Head({ eyebrow, title, description, center }: { eyebrow?: string; title?: string; description?: string; center?: boolean }) {
  if (!title) return null;
  return (
    <div className={cn('mb-10 max-w-[40rem] sm:mb-12', center && 'mx-auto text-center')}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className={cn('kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
          {description}
        </p>
      )}
    </div>
  );
}

function PriceTag({ value, currency, t, className }: { value: number; currency?: string; t: Texts; className?: string }) {
  const locale = useLocale();
  return value === 0 ? <span className={cn('font-semibold text-success-foreground', className)}>{t.free}</span> : <span className={cn('font-semibold tabular-nums text-foreground', className)}>{formatPrice(locale, value, currency)}</span>;
}

/* ------------------------------------------------------------------ */
/* postcode                                                            */
/* ------------------------------------------------------------------ */

function Postcode(p: ShippingEstimatorProps & { t: Texts }) {
  const { t, currency } = p;
  const countries = p.countries ?? [];
  const [country, setCountry] = useState(p.defaultCountry ?? countries[0]?.code ?? '');
  const [code, setCode] = useState(p.defaultPostcode ?? '');
  const [state, setState] = useState<'idle' | 'checking' | 'done'>(p.defaultChecked ? 'done' : 'idle');
  const [error, setError] = useState(false);
  const [shake, setShake] = useState(0);
  const id = useId();
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);
  const c = countries.find((x) => x.code === country);
  const valid = (v: string) => (c?.pattern ? new RegExp(`^${c.pattern}$`).test(v.trim()) : v.trim().length > 2);
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!valid(code)) {
      setError(true);
      setShake((s) => s + 1);
      return;
    }
    setError(false);
    setState('checking');
    p.onCheck?.(country, code.trim());
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setState('done'), 700);
  };
  const options = p.options ?? [];
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16">
        <div>
          <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
          {state === 'done' ? (
            <div className={cn(CARD, 'flex items-center gap-4 p-4 ps-5')}>
              <span className={cn('grid size-11 shrink-0 place-items-center rounded-full', ACCENT_SOFT)}>
                <Icon name="map-pin" size={19} />
              </span>
              <p className="min-w-0 flex-1 text-[14px] text-muted-foreground">
                {t.deliveringTo} {c?.label}
                <span className="block text-[16px] font-semibold tabular-nums text-foreground">
                  {code.trim()} {p.place}
                </span>
              </p>
              <Button variant="ghost" size="sm" onClick={() => setState('idle')}>
                {t.change}
              </Button>
            </div>
          ) : (
            <form noValidate onSubmit={submit} className={cn(CARD, 'p-5 sm:p-6')}>
              <div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
                <div>
                  <label htmlFor={`${id}-c`} className="mb-2 block text-[14px] font-medium text-foreground">
                    {t.country}
                  </label>
                  <NativeSelect id={`${id}-c`} value={country} onChange={(e) => { setCountry(e.target.value); setError(false); }} options={countries.map((x) => ({ value: x.code, label: x.label }))} />
                </div>
                <div>
                  <label htmlFor={`${id}-p`} className="mb-2 block text-[14px] font-medium text-foreground">
                    {t.postcode}
                  </label>
                  <input
                    id={`${id}-p`}
                    key={error ? `e${shake}` : 'ok'}
                    value={code}
                    onChange={(e) => setCode(e.target.value)}
                    inputMode={c?.pattern?.includes('\\d') ? 'numeric' : undefined}
                    autoComplete="postal-code"
                    placeholder={c?.example}
                    aria-invalid={error || undefined}
                    aria-describedby={error ? `${id}-e` : undefined}
                    className={cn(
                      'h-11 w-full rounded-lg border bg-background px-3.5 text-[15px] tabular-nums text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]',
                      error ? 'animate-[kitshop-shake_0.4s_var(--ease-kit)] border-destructive' : 'border-input hover:border-border-strong focus:border-border-strong',
                    )}
                  />
                </div>
              </div>
              {error && (
                <p id={`${id}-e`} role="alert" className="mt-2 flex items-center gap-1.5 text-[13px] text-destructive">
                  <Icon name="alert-circle" size={14} />
                  {t.invalid} {c?.example}
                </p>
              )}
              <Button type="submit" className="mt-5 w-full sm:w-auto" iconLeft={state === 'checking' ? 'loader' : 'truck'} disabled={state === 'checking'}>
                <Swap value={state}>{state === 'checking' ? t.checking : t.check}</Swap>
              </Button>
            </form>
          )}
          {p.freeFrom !== undefined && <FreeNote freeFrom={p.freeFrom} currency={currency} t={t} />}
        </div>

        <div className="min-w-0 lg:pt-2">
          <h3 className="sr-only">{t.options}</h3>
          <ul className={cn('grid grid-cols-1 gap-3 transition-opacity duration-300', state === 'checking' && 'opacity-40')} aria-busy={state === 'checking'} aria-live="polite">
            {options.map((o, i) => (
              <li
                key={`${o.id}-${state}`}
                className={cn(CARD, 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 p-4 sm:p-5', state === 'done' && 'animate-[kitshop-in_0.5s_var(--ease-kit)_both]')}
                style={state === 'done' ? { animationDelay: `${i * 90}ms` } : undefined}
              >
                <span className="grid size-11 place-items-center rounded-xl bg-muted text-foreground">
                  <Icon name={o.icon ?? 'truck'} size={19} />
                </span>
                <span className="min-w-0">
                  <span className="flex flex-wrap items-center gap-2">
                    <span className="text-[15px] font-semibold text-foreground">{o.name}</span>
                    {o.tag && <span className={cn('rounded-full px-2 py-0.5 text-[11.5px] font-medium', ACCENT_SOFT)}>{o.tag}</span>}
                  </span>
                  {o.note && <span className="mt-0.5 block text-[13px] text-muted-foreground sm:truncate">{o.note}</span>}
                </span>
                <span className="text-right">
                  {state === 'done' ? (
                    <span className="block text-[14.5px] font-medium text-foreground">
                      {o.etaNote ? `${o.etaNote}, ` : ''}
                      {o.eta}
                    </span>
                  ) : (
                    <span className="block text-[13px] text-subtle-foreground">{t.datePending}</span>
                  )}
                  <PriceTag value={o.price} currency={currency} t={t} className="mt-0.5 block text-[13.5px]" />
                </span>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </Section>
  );
}

function FreeNote({ freeFrom, currency, t }: { freeFrom: number; currency?: string; t: Texts }) {
  const locale = useLocale();
  return (
    <p className="mt-4 flex items-center gap-2 text-[13.5px] text-muted-foreground">
      <Icon name="gift" size={15} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />
      {t.freeFrom} <span className="font-medium tabular-nums text-foreground">{formatPrice(locale, freeFrom, currency)}</span>
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* countdown                                                           */
/* ------------------------------------------------------------------ */

/** Minutes that count down once a minute after hydration, starting from the given value */
function useMinutes(start: number) {
  const [left, setLeft] = useState(start);
  useEffect(() => {
    setLeft(start);
    const t = window.setInterval(() => setLeft((m) => Math.max(0, m - 1)), 60_000);
    return () => window.clearInterval(t);
  }, [start]);
  return left;
}

function Countdown(p: ShippingEstimatorProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const total = p.minutesLeft ?? 0;
  const left = useMinutes(total);
  const [fast, setFast] = useState(false);
  const steps = p.steps ?? [];
  const arrival = p.arrival ?? { standard: '' };
  const h = Math.floor(left / 60);
  const m = left % 60;
  const ring = round(2 * Math.PI * 26);
  // The ring empties over the last 8 hours before the cut-off
  const share = Math.min(1, left / 480);
  const line = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.55 });
  const [ref, inView] = useInView<HTMLDivElement>();
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div ref={ref} className={cn(CARD, 'overflow-hidden')}>
        <div className="grid grid-cols-1 gap-6 p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:gap-10">
          <div className="flex items-center gap-5">
            <span className="relative grid size-16 shrink-0 place-items-center" aria-hidden>
              <svg viewBox="0 0 60 60" className="absolute inset-0 -rotate-90">
                <circle cx="30" cy="30" r="26" fill="none" strokeWidth="4" className="stroke-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                <circle
                  cx="30"
                  cy="30"
                  r="26"
                  fill="none"
                  strokeWidth="4"
                  strokeLinecap="round"
                  className="stroke-primary transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
                  strokeDasharray={ring}
                  strokeDashoffset={round(ring * (1 - (inView ? share : 0)))}
                />
              </svg>
              <Icon name="clock" size={20} className="text-foreground" />
            </span>
            <p className="text-pretty text-[17px] leading-snug text-muted-foreground sm:text-[19px]">
              {left > 0 ? (
                <>
                  {t.orderWithin}{' '}
                  <span className="font-semibold tabular-nums text-foreground">
                    {h > 0 && `${h} ${t.h} `}
                    {`${m} ${t.min}`}
                  </span>{' '}
                  {t.andGet}{' '}
                </>
              ) : (
                <>{t.cutoffPassed} </>
              )}
              <Swap value={fast}>
                <span className="font-semibold text-foreground">{fast && arrival.fast ? arrival.fast : arrival.standard}</span>
              </Swap>
            </p>
          </div>
          {arrival.fast && (
            <div role="radiogroup" aria-label={t.method} className="relative grid grid-cols-2 rounded-xl border border-border-strong bg-surface p-1 lg:w-[22rem]">
              <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-lg bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${fast ? 100 : 0}%)` }} />
              {[false, true].map((f) => (
                <button
                  key={String(f)}
                  type="button"
                  role="radio"
                  aria-checked={fast === f}
                  onClick={() => setFast(f)}
                  className={cn('relative z-10 flex min-h-12 flex-col items-center justify-center rounded-lg px-3 py-1.5 text-center transition-colors', fast === f ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', FOCUS)}
                >
                  <span className="text-[13.5px] font-medium">{f ? arrival.fastName ?? t.faster : arrival.standardName ?? t.standard}</span>
                  <span className="text-[12px] tabular-nums opacity-80">{f ? (arrival.fastPrice ? `+${formatPrice(locale, arrival.fastPrice, currency)}` : t.free) : t.free}</span>
                </button>
              ))}
            </div>
          )}
        </div>
        {steps.length > 0 && (
          <div className="border-t border-border bg-surface px-6 py-7 sm:px-8">
            <div ref={line} className="relative">
              {/* The line between the steps fills while scrolling: down on phones, across from tablet on */}
              <span aria-hidden className="absolute bottom-5 left-[21px] top-5 w-0.5 rounded-full bg-border sm:hidden" />
              <span aria-hidden className="absolute left-[21px] top-5 h-[calc(100%-2.5rem)] w-0.5 origin-top rounded-full bg-primary sm:hidden" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
              <div className="relative hidden sm:block">
                <span aria-hidden className="absolute top-[21px] h-0.5 rounded-full bg-border" style={{ left: `${round(50 / steps.length, 2)}%`, right: `${round(50 / steps.length, 2)}%` }} />
                <span
                  aria-hidden
                  className="absolute top-[21px] h-0.5 origin-left rounded-full bg-primary"
                  style={{ left: `${round(50 / steps.length, 2)}%`, right: `${round(50 / steps.length, 2)}%`, transform: 'scaleX(var(--kit-progress, 1))' }}
                />
                <ol className="relative grid" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>
                  {steps.map((s, i) => (
                    <StepItem key={s.label} step={s} i={i} fast={fast} />
                  ))}
                </ol>
              </div>
              <ol className="relative grid gap-6 sm:hidden">
                {steps.map((s, i) => (
                  <StepItem key={s.label} step={s} i={i} fast={fast} row />
                ))}
              </ol>
            </div>
          </div>
        )}
      </div>
    </Section>
  );
}

function StepItem({ step, i, fast, row }: { step: ShippingStep; i: number; fast: boolean; row?: boolean }) {
  const date = fast && step.fastDate ? step.fastDate : step.date;
  return (
    <li className={cn('relative', row ? 'flex items-center gap-4' : 'flex flex-col items-center text-center')}>
      <span className={cn('relative z-10 grid size-11 shrink-0 place-items-center rounded-full border-2 border-surface', i === 0 ? 'bg-primary text-primary-foreground' : 'bg-card text-foreground shadow-[0_0_0_1px_var(--border)]')}>
        <Icon name={step.icon ?? 'package'} size={18} />
      </span>
      <span className={cn(!row && 'mt-3')}>
        <span className="block text-[14px] font-medium text-foreground">{step.label}</span>
        <Swap value={date}>
          <span className="block text-[13px] tabular-nums text-muted-foreground">{date}</span>
        </Swap>
      </span>
    </li>
  );
}

/* ------------------------------------------------------------------ */
/* rates-table                                                         */
/* ------------------------------------------------------------------ */

function RatesTable(p: ShippingEstimatorProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const zones = p.zones ?? [];
  const table = (z: ShippingZone) => (
    <div>
      <div className={cn(CARD, 'hidden overflow-hidden sm:block')}>
        <table className="w-full text-left text-[14px]">
          <thead className="bg-surface text-[12.5px] text-muted-foreground">
            <tr>
              <th scope="col" className="px-5 py-3 font-medium">{t.method}</th>
              <th scope="col" className="px-5 py-3 font-medium">{t.time}</th>
              <th scope="col" className="px-5 py-3 text-right font-medium">{t.cost}</th>
              <th scope="col" className="px-5 py-3 text-right font-medium">{t.freeFrom}</th>
            </tr>
          </thead>
          <tbody className="divide-y divide-border border-t border-border">
            {z.rows.map((r, i) => (
              <tr key={r.method} className="animate-[kitshop-in_0.45s_var(--ease-kit)_both] transition-colors hover:bg-surface" style={{ animationDelay: `${i * 60}ms` }}>
                <th scope="row" className="px-5 py-4 font-medium text-foreground">
                  {r.method}
                  {r.note && <span className="mt-0.5 block text-[12.5px] font-normal text-muted-foreground">{r.note}</span>}
                </th>
                <td className="px-5 py-4 text-muted-foreground">{r.time}</td>
                <td className="px-5 py-4 text-right">
                  <PriceTag value={r.price} currency={currency} t={t} />
                </td>
                <td className="px-5 py-4 text-right tabular-nums text-muted-foreground">{r.freeFrom !== undefined ? formatPrice(locale, r.freeFrom, currency) : ''}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <ul className="grid grid-cols-1 gap-3 sm:hidden">
        {z.rows.map((r, i) => (
          <li key={r.method} className={cn(CARD, 'animate-[kitshop-in_0.45s_var(--ease-kit)_both] p-4')} style={{ animationDelay: `${i * 60}ms` }}>
            <div className="flex items-baseline justify-between gap-3">
              <p className="text-[15px] font-semibold text-foreground">{r.method}</p>
              <PriceTag value={r.price} currency={currency} t={t} className="text-[15px]" />
            </div>
            <p className="mt-1 text-[13.5px] text-muted-foreground">{r.time}</p>
            {(r.freeFrom !== undefined || r.note) && (
              <p className="mt-2 border-t border-border pt-2 text-[12.5px] text-muted-foreground">
                {r.freeFrom !== undefined && (
                  <>
                    {t.freeFrom} <span className="tabular-nums text-foreground">{formatPrice(locale, r.freeFrom, currency)}</span>
                  </>
                )}
                {r.freeFrom !== undefined && r.note && ' · '}
                {r.note}
              </p>
            )}
          </li>
        ))}
      </ul>
      {z.note && (
        <p className="mt-4 flex items-start gap-2 text-[13px] text-muted-foreground">
          <Icon name="info" size={15} className="mt-px shrink-0" />
          {z.note}
        </p>
      )}
    </div>
  );
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <Reveal effect="rise">
        <Tabs label={t.region} variant="segmented" items={zones.map((z) => ({ value: z.id, label: z.label, content: table(z) }))} panelClassName="pt-6" />
      </Reveal>
      {p.notes?.length ? (
        <Stagger step={60} as="ul" className="mt-10 grid grid-cols-1 gap-3 border-t border-border pt-8 sm:grid-cols-2 lg:grid-cols-3">
          {p.notes.map((n) => (
            <li key={n} className="flex items-start gap-3 text-[14px] leading-relaxed text-muted-foreground">
              <Icon name="check-circle" size={16} className="mt-0.5 shrink-0 text-success-foreground" />
              {n}
            </li>
          ))}
        </Stagger>
      ) : null}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* delivery-slots                                                      */
/* ------------------------------------------------------------------ */

function DeliverySlots(p: ShippingEstimatorProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const days = p.days ?? [];
  const [dayId, setDayId] = useState(p.defaultDay ?? days[0]?.id ?? '');
  const day = days.find((d) => d.id === dayId) ?? days[0];
  const firstOpen = (d?: DeliveryDay) => d?.slots.find((s) => !s.full)?.id ?? '';
  const [slotId, setSlotId] = useState(p.defaultSlot ?? firstOpen(day));
  const [booked, setBooked] = useState(false);
  const slot = day?.slots.find((s) => s.id === slotId);
  const strip = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ x: number; w: number; ready: boolean } | null>(null);
  const index = days.findIndex((d) => d.id === dayId);
  useEffect(() => {
    const el = strip.current;
    if (!el) return;
    const update = () => {
      const b = el.querySelectorAll<HTMLElement>('[data-day]')[index];
      if (b) setMark((m) => ({ x: b.offsetLeft, w: b.offsetWidth, ready: !!m }));
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, [index]);
  const pickDay = (d: DeliveryDay) => {
    setDayId(d.id);
    setBooked(false);
    if (!d.slots.some((s) => s.id === slotId && !s.full)) setSlotId(firstOpen(d));
  };
  const scroll = (dir: 1 | -1) => strip.current?.scrollBy({ left: dir * 240, behavior: 'smooth' });
  const onKey = (e: React.KeyboardEvent) => {
    if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
    e.preventDefault();
    const next = days[Math.max(0, Math.min(days.length - 1, index + (e.key === 'ArrowRight' ? 1 : -1)))];
    pickDay(next);
    requestAnimationFrame(() => strip.current?.querySelectorAll<HTMLElement>('[data-day]')[days.indexOf(next)]?.focus());
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-12">
        <div className="min-w-0">
          <div className="flex items-center justify-between gap-4">
            <h3 className="text-[15px] font-semibold text-foreground">{t.pickDay}</h3>
            <div className="flex gap-2 max-sm:hidden">
              <button type="button" onClick={() => scroll(-1)} aria-label={t.earlier} className={cn('grid size-10 place-items-center rounded-full border border-border-strong text-foreground hover:bg-accent', FOCUS)}>
                <Icon name="chevron-left" size={16} />
              </button>
              <button type="button" onClick={() => scroll(1)} aria-label={t.later} className={cn('grid size-10 place-items-center rounded-full border border-border-strong text-foreground hover:bg-accent', FOCUS)}>
                <Icon name="chevron-right" size={16} />
              </button>
            </div>
          </div>
          <div ref={strip} role="radiogroup" aria-label={t.pickDay} onKeyDown={onKey} className="kit-no-scrollbar kit-fade-end relative -mx-6 mt-4 flex gap-2 overflow-x-auto px-6 py-1 sm:mx-0 sm:px-0">
            {mark && (
              <span
                aria-hidden
                className={cn('absolute bottom-1 left-0 top-1 rounded-xl bg-foreground', mark.ready && 'transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
                style={{ width: mark.w, transform: `translateX(${mark.x}px)` }}
              />
            )}
            {days.map((d) => {
              const on = d.id === dayId;
              const open = d.slots.some((s) => !s.full);
              const from = Math.min(...d.slots.filter((s) => !s.full).map((s) => s.price));
              return (
                <button
                  key={d.id}
                  data-day
                  type="button"
                  role="radio"
                  aria-checked={on}
                  aria-label={d.label}
                  tabIndex={on ? 0 : -1}
                  disabled={!open}
                  onClick={() => pickDay(d)}
                  className={cn(
                    'relative z-10 flex w-[4.75rem] shrink-0 flex-col items-center rounded-xl border py-3 transition-colors duration-300 disabled:cursor-not-allowed disabled:opacity-40',
                    on ? (mark ? 'border-transparent text-background' : 'border-foreground bg-foreground text-background') : 'border-border bg-card text-foreground hover:border-border-strong',
                    FOCUS,
                  )}
                >
                  <span className={cn('text-[12px]', on ? 'opacity-80' : 'text-muted-foreground')}>{d.weekday}</span>
                  <span className="text-[22px] font-semibold leading-tight tracking-[-0.02em] tabular-nums">{d.day}</span>
                  <span className={cn('text-[11.5px] tabular-nums', on ? 'opacity-80' : 'text-muted-foreground')}>{open ? (from === 0 ? t.free : formatPrice(locale, from, currency)) : t.full}</span>
                </button>
              );
            })}
          </div>

          <h3 className="mt-8 text-[15px] font-semibold text-foreground">{t.pickSlot}</h3>
          <div key={dayId} role="radiogroup" aria-label={t.pickSlot} className={cn('mt-4 grid grid-cols-2 gap-2', (day?.slots.length ?? 0) === 4 ? 'sm:grid-cols-4' : (day?.slots.length ?? 0) === 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-3')}>
            {day?.slots.map((s, i) => {
              const on = s.id === slotId;
              return (
                <label
                  key={s.id}
                  className={cn(
                    'relative flex min-h-16 cursor-pointer flex-col justify-center rounded-xl border px-4 py-3 animate-[kitshop-in_0.4s_var(--ease-kit)_both] transition-[border-color,background-color,box-shadow] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
                    s.full ? 'cursor-not-allowed border-border bg-surface text-subtle-foreground' : on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong',
                  )}
                  style={{ animationDelay: `${i * 50}ms` }}
                >
                  <input type="radio" name="kit-slot" value={s.id} checked={on} disabled={s.full} onChange={() => { setSlotId(s.id); setBooked(false); }} className="sr-only" />
                  <span className={cn('text-[14.5px] font-medium tabular-nums', !s.full && 'text-foreground', s.full && 'line-through decoration-1')}>{s.label}</span>
                  <span className="mt-0.5 flex items-center gap-2 text-[12.5px]">
                    {s.full ? <span>{t.full}</span> : <PriceTag value={s.price} currency={currency} t={t} className="font-normal text-muted-foreground" />}
                    {s.tag && !s.full && <span className={cn('rounded-full px-1.5 py-px text-[11px] font-medium', ACCENT_SOFT)}>{s.tag}</span>}
                  </span>
                  {on && <Icon name="check-circle" size={16} className="absolute right-3 top-3 animate-[kitshop-pop_0.35s_var(--ease-kit)] text-foreground" />}
                </label>
              );
            })}
          </div>
        </div>

        <Reveal effect="rise" className="lg:sticky lg:top-8 lg:self-start">
          <div className={cn(CARD, 'p-6')}>
            <p className="text-[13px] text-muted-foreground">{t.yourSlot}</p>
            <Swap value={`${dayId}-${slotId}`}>
              <span className="mt-1 block text-[18px] font-semibold tracking-[-0.01em] text-foreground">{day?.label}</span>
            </Swap>
            <p className="mt-0.5 flex items-center justify-between gap-3 text-[14.5px] text-muted-foreground">
              <span className="tabular-nums">{slot?.label ?? '·'}</span>
              {slot && <PriceTag value={slot.price} currency={currency} t={t} />}
            </p>
            <Button
              className="mt-5 w-full"
              size="lg"
              disabled={!slot || booked}
              iconLeft={booked ? 'check' : 'calendar-check'}
              onClick={() => {
                if (!day || !slot) return;
                setBooked(true);
                p.onSelect?.(day, slot.id);
              }}
            >
              <Swap value={booked}>{booked ? t.confirmed : t.confirm}</Swap>
            </Button>
            {p.freeFrom !== undefined && <FreeNote freeFrom={p.freeFrom} currency={currency} t={t} />}
          </div>
        </Reveal>
      </div>
    </Section>
  );
}

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

/**
 * Delivery dates and shipping costs in four layouts. postcode: options for
 * an address. countdown: the cut-off of today and the way to the door.
 * rates-table: rates per region. delivery-slots: a day and a time window.
 */
export function ShippingEstimator(props: ShippingEstimatorProps) {
  const { variant = 'postcode' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'countdown') return <Countdown {...props} t={t} />;
  if (variant === 'rates-table') return <RatesTable {...props} t={t} />;
  if (variant === 'delivery-slots') return <DeliverySlots {...props} t={t} />;
  return <Postcode {...props} t={t} />;
}

Frequent questions

How do I install Shipping estimator?

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

Which variants does Shipping estimator have?

4 variants, Postcode check, Order cut-off, Rates by region and Delivery window. You pick one with the variant prop, for example variant="countdown".

Can I translate the texts of Shipping estimator?

Yes. Shipping estimator carries 31 texts of its own in English and German, such as “Country”, “Postcode” and “Show delivery dates”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Shipping estimator?

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

Can I adjust Shipping estimator without code?

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

Often searched

Jump to

Copied to the clipboard