Skip to content
UI Kit
Accent color

Stock status

What is there, what is almost gone, what comes back, what can be ordered ahead and where it can be picked up today. Every state is said in words, the color only supports it.

One of 4 components in Delivery and stores

5 variants

Variants

Stock line

The stock line in six states on product cards, from in stock and few left to sold out, preorder and made to order. Live states breathe, the pieces-left bar runs down when it scrolls in, sold out turns into "Notify me".

Open in Studio

Stock per size

Sizes with a dot for their stock, sold out sizes struck through but pickable. The picked size says what is left in words, swaps with a short rise and shows a bar of the last pieces, a sold out size gives its restock date and "Notify me".

Open in Studio

Back in stock alert

A sold out product with size choice, email and an extra box for price drops. The expected date fades in per size, a missing size or email shakes and says what to do, the confirmation draws its check.

Open in Studio

Preorder

A product before its release, with days to go on the image, the first batch that fills while scrolling, pay now or a deposit, and the way from order to door. Preordering counts itself into the batch.

Open in Studio

Available in store

Which store has the picked size today, with distance, hours and pieces in store. Changing the size lets the list run in again and moves the choice to a store that has it, reserving holds it for 48 hours.

Open in Studio

Details

What Stock status brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/stock-status.tsx
Brings along
utilskitbuttoniconrevealmotionsectionproduct-image
npm packages
None
Languages
English and German, 48 texts of its own

Props

The StockStatusProps interface from components/ui/stock-status.tsx, with the comments from the code.

  • variantoptional
    StockStatusVariant

    inlinelow-stockback-in-stockpreorderstore-availability

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • linesoptional
    StockLine[]

    inline: the states to show, one per card

  • productoptional
    StockProduct
  • sizesoptional
    SizeStock[]
  • defaultSizeoptional
    string
  • optionLabeloptional
    string

    What the options are called, e.g. "Pick your color". Default "Pick your size".

  • lowFromoptional
    number

    low-stock: from how many pieces a size counts as low

  • preorderoptional
    { release: string; days?: number; reserved: number; total: number; shipNote?: string; deposit?: number; steps?: { label: string; date: string }[] }

    preorder

  • storesoptional
    StoreStock[]
  • currencyoptional
    string
  • onNotifyoptional
    (size: string, email: string) => void
  • onPreorderoptional
    (pay: 'now' | 'later') => void
  • onReserveoptional
    (store: StoreStock, size: string) => void

Built-in texts

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

Show all 48 texts
sizeEnglish SizeGerman Größe
pickSizeEnglish Pick your sizeGerman Wähle deine Größe
soldOutEnglish Sold outGerman Ausverkauft
fewEnglish Few leftGerman Nur noch wenige
inStockEnglish In stockGerman Auf Lager
leftEnglish leftGerman übrig
onlyLeftEnglish OnlyGerman Nur noch
leftInEnglish left inGerman in
plentyEnglish In stock inGerman Auf Lager in
shipsTomorrowEnglish Ships tomorrowGerman Versand morgen
backOnEnglish BackGerman Wieder da
notifyMeEnglish Notify meGerman Benachrichtigen
addToCartEnglish Add to cartGerman In den Warenkorb
notifyTitleEnglish Tell me when it is backGerman Sag mir, wenn es wieder da ist
emailEnglish EmailGerman E-Mail
emailPlaceholderEnglish you@example.comGerman du@beispiel.de
emailErrorEnglish Enter your email so we can write to you.German Gib deine E-Mail ein, damit wir dir schreiben können.
sizeErrorEnglish Pick one option first.German Wähle zuerst eine Option.
alsoPriceEnglish Also tell me about price dropsGerman Auch bei Preissenkungen Bescheid geben
notifyButtonEnglish Notify me when it is backGerman Benachrichtigen, sobald es da ist
notifyDoneEnglish You are on the listGerman Du stehst auf der Liste
notifyDoneTextEnglish We write once, the day it is back. No newsletter, no other mail.German Wir schreiben einmal, an dem Tag, an dem es wieder da ist. Kein Newsletter, keine andere Mail.
expectedEnglish ExpectedGerman Erwartet
preorderBadgeEnglish PreorderGerman Vorbestellung
priceEnglish PriceGerman Preis
releasesEnglish ReleasesGerman Erscheint
daysToGoEnglish days to goGerman Tage bis dahin
reservedEnglish reservedGerman reserviert
ofEnglish ofGerman von
firstBatchEnglish First batchGerman Erste Auflage
payNowEnglish Pay nowGerman Jetzt bezahlen
payNowNoteEnglish The full price todayGerman Der volle Preis heute
payLaterEnglish Pay at shippingGerman Bei Versand bezahlen
payLaterNoteEnglish A deposit today, the rest when it shipsGerman Heute eine Anzahlung, der Rest beim Versand
depositEnglish depositGerman Anzahlung
preorderButtonEnglish Preorder nowGerman Jetzt vorbestellen
preorderedEnglish PreorderedGerman Vorbestellt
cancelAnytimeEnglish Cancel any time before it ships, free of charge.German Bis zum Versand jederzeit kostenlos stornierbar.
pickupEnglish Pick up in storeGerman Im Laden abholen
storesEnglish StoresGerman Läden
storesCountEnglish storesGerman Läden
inStoreEnglish in storeGerman im Laden
notInStoreEnglish Not in this storeGerman In diesem Laden nicht da
onlyOneEnglish Last pieceGerman Letztes Stück
reserveEnglish Reserve for pickupGerman Zum Abholen reservieren
reservedDoneEnglish Reserved for youGerman Für dich reserviert
reservedNoteEnglish We hold it for 48 hours. You pay when you pick it up.German Wir legen es 48 Stunden zurück. Bezahlt wird bei der Abholung.
availableEnglish availableGerman verfügbar

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles alert, radiogroup and status.
  • 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, 847 lines
components/ui/stock-status.tsx
'use client';

import { useId, 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 } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { Price, ProductMedia, ShopMotion, Swap, formatNumber, formatPrice } from '@/components/ui/product-image';

/*
 * Stock on the product page: what is there, what is almost gone, what comes
 * back, what can be ordered ahead and where it can be picked up today.
 *
 *   inline              the stock line in all its states, on product cards
 *   low-stock           sizes with stock per size, a bar for the picked size, restock dates
 *   back-in-stock       sold out: pick the size, leave an email, get a note when it is back
 *   preorder            a release date, the batch that fills, pay now or at shipping
 *   store-availability  which store has the picked size today, reserve for pickup
 *
 * Every state is said in words, the color only supports it.
 */

export type StockStatusVariant = 'inline' | 'low-stock' | 'back-in-stock' | 'preorder' | 'store-availability';
export type StockKind = 'in' | 'low' | 'out' | 'preorder' | 'made-to-order' | 'last';

export interface StockProduct {
  name: string;
  /** e.g. "Stone · Mesh and suede" */
  variant?: string;
  price: number;
  compareAt?: number;
  image?: React.ReactNode;
  href?: string;
}

export interface StockLine {
  kind: StockKind;
  /** The line itself, e.g. "Only 3 left" */
  label: string;
  /** A second line, e.g. "Ships tomorrow" or "Back on Nov 12" */
  note?: string;
  /** Pieces left, shown as a bar for low and last */
  count?: number;
  product?: StockProduct;
}

export interface SizeStock {
  label: string;
  stock: number;
  /** For sold out sizes, e.g. "Back on Nov 12" */
  restock?: string;
}

export interface StoreStock {
  id: string;
  name: string;
  address: string;
  /** Already formatted, e.g. "1.2 km" */
  distance?: string;
  /** e.g. "Open until 8 pm" */
  hours?: string;
  /** Pieces per size label */
  stock: Record<string, number>;
  /** e.g. "Ready in 2 hours" */
  ready?: string;
}

export interface StockStatusProps {
  variant?: StockStatusVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** inline: the states to show, one per card */
  lines?: StockLine[];
  product?: StockProduct;
  sizes?: SizeStock[];
  defaultSize?: string;
  /** What the options are called, e.g. "Pick your color". Default "Pick your size". */
  optionLabel?: string;
  /** low-stock: from how many pieces a size counts as low */
  lowFrom?: number;
  /** preorder */
  preorder?: { release: string; days?: number; reserved: number; total: number; shipNote?: string; deposit?: number; steps?: { label: string; date: string }[] };
  stores?: StoreStock[];
  currency?: string;
  onNotify?: (size: string, email: string) => void;
  onPreorder?: (pay: 'now' | 'later') => void;
  onReserve?: (store: StoreStock, size: string) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    size: 'Size',
    pickSize: 'Pick your size',
    soldOut: 'Sold out',
    few: 'Few left',
    inStock: 'In stock',
    left: 'left',
    onlyLeft: 'Only',
    leftIn: 'left in',
    plenty: 'In stock in',
    shipsTomorrow: 'Ships tomorrow',
    backOn: 'Back',
    notifyMe: 'Notify me',
    addToCart: 'Add to cart',
    notifyTitle: 'Tell me when it is back',
    email: 'Email',
    emailPlaceholder: 'you@example.com',
    emailError: 'Enter your email so we can write to you.',
    sizeError: 'Pick one option first.',
    alsoPrice: 'Also tell me about price drops',
    notifyButton: 'Notify me when it is back',
    notifyDone: 'You are on the list',
    notifyDoneText: 'We write once, the day it is back. No newsletter, no other mail.',
    expected: 'Expected',
    preorderBadge: 'Preorder',
    price: 'Price',
    releases: 'Releases',
    daysToGo: 'days to go',
    reserved: 'reserved',
    of: 'of',
    firstBatch: 'First batch',
    payNow: 'Pay now',
    payNowNote: 'The full price today',
    payLater: 'Pay at shipping',
    payLaterNote: 'A deposit today, the rest when it ships',
    deposit: 'deposit',
    preorderButton: 'Preorder now',
    preordered: 'Preordered',
    cancelAnytime: 'Cancel any time before it ships, free of charge.',
    pickup: 'Pick up in store',
    stores: 'Stores',
    storesCount: 'stores',
    inStore: 'in store',
    notInStore: 'Not in this store',
    onlyOne: 'Last piece',
    reserve: 'Reserve for pickup',
    reservedDone: 'Reserved for you',
    reservedNote: 'We hold it for 48 hours. You pay when you pick it up.',
    available: 'available',
  },
  de: {
    size: 'Größe',
    pickSize: 'Wähle deine Größe',
    soldOut: 'Ausverkauft',
    few: 'Nur noch wenige',
    inStock: 'Auf Lager',
    left: 'übrig',
    onlyLeft: 'Nur noch',
    leftIn: 'in',
    plenty: 'Auf Lager in',
    shipsTomorrow: 'Versand morgen',
    backOn: 'Wieder da',
    notifyMe: 'Benachrichtigen',
    addToCart: 'In den Warenkorb',
    notifyTitle: 'Sag mir, wenn es wieder da ist',
    email: 'E-Mail',
    emailPlaceholder: 'du@beispiel.de',
    emailError: 'Gib deine E-Mail ein, damit wir dir schreiben können.',
    sizeError: 'Wähle zuerst eine Option.',
    alsoPrice: 'Auch bei Preissenkungen Bescheid geben',
    notifyButton: 'Benachrichtigen, sobald es da ist',
    notifyDone: 'Du stehst auf der Liste',
    notifyDoneText: 'Wir schreiben einmal, an dem Tag, an dem es wieder da ist. Kein Newsletter, keine andere Mail.',
    expected: 'Erwartet',
    preorderBadge: 'Vorbestellung',
    price: 'Preis',
    releases: 'Erscheint',
    daysToGo: 'Tage bis dahin',
    reserved: 'reserviert',
    of: 'von',
    firstBatch: 'Erste Auflage',
    payNow: 'Jetzt bezahlen',
    payNowNote: 'Der volle Preis heute',
    payLater: 'Bei Versand bezahlen',
    payLaterNote: 'Heute eine Anzahlung, der Rest beim Versand',
    deposit: 'Anzahlung',
    preorderButton: 'Jetzt vorbestellen',
    preordered: 'Vorbestellt',
    cancelAnytime: 'Bis zum Versand jederzeit kostenlos stornierbar.',
    pickup: 'Im Laden abholen',
    stores: 'Läden',
    storesCount: 'Läden',
    inStore: 'im Laden',
    notInStore: 'In diesem Laden nicht da',
    onlyOne: 'Letztes Stück',
    reserve: 'Zum Abholen reservieren',
    reservedDone: 'Für dich reserviert',
    reservedNote: 'Wir legen es 48 Stunden zurück. Bezahlt wird bei der Abholung.',
    available: 'verfügbar',
  },
};

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_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';

const KIND: Record<StockKind, { dot: string; text: string; icon: IconName }> = {
  in: { dot: 'bg-success', text: 'text-success-foreground', icon: 'check-circle' },
  low: { dot: 'bg-warning', text: 'text-warning-foreground', icon: 'hourglass' },
  last: { dot: 'bg-destructive', text: 'text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]', icon: 'flame' },
  out: { dot: 'bg-subtle-foreground', text: 'text-muted-foreground', icon: 'ban' },
  preorder: { dot: 'bg-primary', text: ACCENT_TEXT, icon: 'calendar' },
  'made-to-order': { dot: 'bg-primary', text: ACCENT_TEXT, icon: 'wrench' },
};

function Head({ eyebrow, title, description }: { eyebrow?: string; title?: string; description?: string }) {
  if (!title) return null;
  return (
    <div className="mb-10 max-w-[40rem] sm:mb-12">
      {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>
  );
}

/** The dot that breathes for live states (in stock, low), still for the others */
function Dot({ kind }: { kind: StockKind }) {
  const live = kind === 'in' || kind === 'low' || kind === 'last';
  return (
    <span className="relative flex size-2 shrink-0" aria-hidden>
      {live && <span className={cn('absolute inset-0 animate-kit-pulse rounded-full', KIND[kind].dot)} />}
      <span className={cn('relative size-2 rounded-full', KIND[kind].dot)} />
    </span>
  );
}

/** A bar of pieces left that runs down to its value when it scrolls in */
function LeftBar({ count, max = 10, kind }: { count: number; max?: number; kind: StockKind }) {
  const [ref, inView] = useInView<HTMLSpanElement>();
  return (
    <span ref={ref} className="relative mt-2 block h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
      <span
        className={cn('absolute inset-0 origin-left rounded-full transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', KIND[kind].dot)}
        style={{ transform: `scaleX(${inView ? round(Math.min(1, count / max), 3) : 1})` }}
      />
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* inline                                                              */
/* ------------------------------------------------------------------ */

function Inline(p: StockStatusProps & { t: Texts }) {
  const { t, currency } = p;
  const lines = p.lines ?? [];
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <Stagger step={70} as="ul" className="grid grid-cols-2 gap-x-3 gap-y-8 sm:grid-cols-3 sm:gap-x-5 lg:gap-x-6">
        {lines.map((l, i) => (
          <li key={i} className="flex flex-col">
            {l.product && (
              <a href={l.product.href ?? '#'} className={cn('group/card block rounded-xl', FOCUS)}>
                <ProductMedia className={cn('aspect-square', l.kind === 'out' && '[&>*]:opacity-60 [&>*]:grayscale')}>
                  <span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.04] motion-reduce:transition-none [&>*]:size-full">{l.product.image}</span>
                </ProductMedia>
                <span className="mt-3 block text-[14.5px] font-medium text-foreground">{l.product.name}</span>
                <Price value={l.product.price} compareAt={l.product.compareAt} currency={currency} className="mt-1" />
              </a>
            )}
            <div className="mt-3 border-t border-border pt-3">
              <p className={cn('flex items-center gap-2 text-[13.5px] font-medium', KIND[l.kind].text)}>
                <Dot kind={l.kind} />
                {l.label}
              </p>
              {l.note && <p className="mt-1 ps-4 text-[12.5px] text-muted-foreground">{l.note}</p>}
              {l.count !== undefined && (l.kind === 'low' || l.kind === 'last') && (
                <span className="block ps-4">
                  <LeftBar count={l.count} kind={l.kind} />
                </span>
              )}
            </div>
            <div className="mt-auto pt-4">
              {l.kind === 'out' ? (
                <NotifyToggle t={t} />
              ) : (
                <Button variant="outline" size="sm" className="h-11 w-full sm:h-10" iconLeft={l.kind === 'preorder' ? 'calendar' : 'bag'}>
                  {l.kind === 'preorder' ? t.preorderButton : t.addToCart}
                </Button>
              )}
            </div>
          </li>
        ))}
      </Stagger>
    </Section>
  );
}

function NotifyToggle({ t }: { t: Texts }) {
  const [on, setOn] = useState(false);
  return (
    <Button variant={on ? 'secondary' : 'outline'} size="sm" aria-pressed={on} className="h-11 w-full sm:h-10" onClick={() => setOn(!on)}>
      <Swap value={on} className="gap-1.5">
        <Icon name={on ? 'check' : 'bell'} size={15} />
        {on ? t.notifyDone : t.notifyMe}
      </Swap>
    </Button>
  );
}

/* ------------------------------------------------------------------ */
/* low-stock                                                           */
/* ------------------------------------------------------------------ */

function SizeButtons({ sizes, value, onChange, lowFrom, t, name }: { sizes: SizeStock[]; value: string; onChange: (s: string) => void; lowFrom: number; t: Texts; name: string }) {
  return (
    <div role="radiogroup" aria-label={t.size} className="grid grid-cols-4 gap-2">
      {sizes.map((s) => {
        const on = s.label === value;
        const out = s.stock <= 0;
        const low = !out && s.stock <= lowFrom;
        return (
          <label
            key={s.label}
            className={cn(
              'relative grid h-14 cursor-pointer place-items-center rounded-lg border text-center transition-[border-color,background-color,color] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
              on ? 'border-foreground bg-foreground text-background' : out ? 'border-border bg-surface text-subtle-foreground hover:border-border-strong' : 'border-border-strong bg-background text-foreground hover:border-[color-mix(in_oklab,var(--foreground)_45%,var(--border-strong))]',
            )}
          >
            <input type="radio" name={name} value={s.label} checked={on} onChange={() => onChange(s.label)} className="sr-only" />
            <span className="leading-none">
              <span className={cn('block text-[14px] font-medium tabular-nums', out && !on && 'line-through decoration-1')}>{s.label}</span>
              <span className={cn('mt-1 flex justify-center')} aria-hidden>
                <span className={cn('size-1.5 rounded-full', out ? 'bg-subtle-foreground/50' : low ? 'bg-warning' : 'bg-success', on && 'ring-2 ring-background')} />
              </span>
            </span>
            <span className="sr-only">, {out ? t.soldOut : low ? t.few : t.inStock}</span>
          </label>
        );
      })}
    </div>
  );
}

function LowStock(p: StockStatusProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const sizes = p.sizes ?? [];
  const lowFrom = p.lowFrom ?? 3;
  const [size, setSize] = useState(p.defaultSize ?? sizes.find((s) => s.stock > 0 && s.stock <= lowFrom)?.label ?? sizes[0]?.label ?? '');
  const cur = sizes.find((s) => s.label === size);
  const out = !cur || cur.stock <= 0;
  const low = !out && cur!.stock <= lowFrom;
  const kind: StockKind = out ? 'out' : cur!.stock === 1 ? 'last' : low ? 'low' : 'in';
  const name = useId();
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center lg:gap-16">
        <Reveal effect="scale">
          <ProductMedia className="aspect-[4/3] lg:aspect-square">{p.product?.image}</ProductMedia>
        </Reveal>
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? p.product?.name}</h2>
          {p.product && (
            <div className="kit-rise mt-3 flex flex-wrap items-baseline gap-x-3" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              <Price value={p.product.price} compareAt={p.product.compareAt} currency={currency} size="lg" />
              {p.product.variant && <span className="text-[14px] text-muted-foreground">{p.product.variant}</span>}
            </div>
          )}
          <fieldset className="mt-8">
            <legend className="mb-3 flex w-full items-baseline justify-between text-[14px] font-medium text-foreground">
              {t.pickSize}
              <span className="flex gap-3 text-[12px] font-normal text-muted-foreground" aria-hidden>
                <span className="inline-flex items-center gap-1.5">
                  <span className="size-1.5 rounded-full bg-success" />
                  {t.inStock}
                </span>
                <span className="inline-flex items-center gap-1.5">
                  <span className="size-1.5 rounded-full bg-warning" />
                  {t.few}
                </span>
              </span>
            </legend>
            <SizeButtons sizes={sizes} value={size} onChange={setSize} lowFrom={lowFrom} t={t} name={name} />
          </fieldset>
          <div className={cn(CARD, 'mt-5 p-4 sm:p-5')} aria-live="polite">
            <Swap value={size} className="w-full">
              <div className="w-full">
                <p className={cn('flex items-center gap-2 text-[15px] font-medium', KIND[kind].text)}>
                  <Dot kind={kind} />
                  {out ? `${t.soldOut} · ${t.size} ${size}` : low ? `${t.onlyLeft} ${formatNumber(locale, cur!.stock)} ${t.leftIn} ${size}` : `${t.plenty} ${size}`}
                </p>
                <p className="mt-1 ps-4 text-[13px] text-muted-foreground">{out ? (cur?.restock ? `${t.backOn} ${cur.restock}` : t.notifyTitle) : t.shipsTomorrow}</p>
                {!out && low && (
                  <span className="block ps-4">
                    <LeftBar count={cur!.stock} max={lowFrom * 3} kind={kind} />
                  </span>
                )}
              </div>
            </Swap>
          </div>
          <div className="mt-5">
            {out ? (
              <NotifyToggle t={t} />
            ) : (
              <Button size="lg" className="w-full" iconLeft="bag">
                {t.addToCart}
              </Button>
            )}
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* back-in-stock                                                       */
/* ------------------------------------------------------------------ */

function BackInStock(p: StockStatusProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const sizes = p.sizes ?? [];
  const [size, setSize] = useState(p.defaultSize ?? '');
  const [email, setEmail] = useState('');
  const [price, setPrice] = useState(false);
  const [tried, setTried] = useState(false);
  const [done, setDone] = useState(false);
  const [shake, setShake] = useState(0);
  const id = useId();
  const emailOk = /^\S+@\S+\.\S+$/.test(email);
  const cur = sizes.find((s) => s.label === size);
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (!size || !emailOk) {
      setShake((s) => s + 1);
      return;
    }
    setDone(true);
    p.onNotify?.(size, email);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className={cn(CARD, 'mx-auto grid max-w-[60rem] grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)]')}>
        <div className="relative bg-surface-2">
          <ProductMedia className="aspect-[4/3] rounded-none md:aspect-auto md:h-full">
            <span className="block size-full opacity-75 grayscale-[0.4] [&>*]:size-full">{p.product?.image}</span>
          </ProductMedia>
          <span className="absolute left-4 top-4 inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-background/90 px-3 text-[12.5px] font-medium text-foreground backdrop-blur">
            <Icon name="ban" size={14} className="text-muted-foreground" />
            {t.soldOut}
          </span>
        </div>
        <div className="p-6 sm:p-8">
          {done ? (
            <div className="flex h-full flex-col justify-center" role="status">
              <span className="grid size-14 animate-[kitshop-pop_0.5s_var(--ease-kit)] place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground">
                <svg viewBox="0 0 24 24" className="size-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
                  <path d="M5 12.5 10 17.5 19 7" strokeDasharray="24" className="animate-[kitshop-draw_0.6s_var(--ease-kit)_0.2s_both]" />
                </svg>
              </span>
              <h2 className="mt-5 text-[22px] font-semibold tracking-[-0.02em] text-foreground">{t.notifyDone}</h2>
              <p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">
                {p.product?.name} · {size}
                {cur?.restock && (
                  <>
                    <br />
                    {t.expected} {cur.restock}
                  </>
                )}
              </p>
              <p className="mt-4 text-pretty text-[13.5px] text-muted-foreground">{t.notifyDoneText}</p>
            </div>
          ) : (
            <form noValidate onSubmit={submit}>
              {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
              <h2 className="text-balance text-h3 font-semibold text-foreground">{p.title ?? t.notifyTitle}</h2>
              {p.product && (
                <p className="mt-2 flex flex-wrap items-baseline gap-x-3 text-[14px] text-muted-foreground">
                  <span className="font-medium text-foreground">{p.product.name}</span>
                  <span className="tabular-nums">{formatPrice(locale, p.product.price, currency)}</span>
                </p>
              )}
              {p.description && <p className="mt-3 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{p.description}</p>}
              <fieldset className="mt-6">
                <legend className="mb-3 text-[14px] font-medium text-foreground">{p.optionLabel ?? t.pickSize}</legend>
                <div key={tried && !size ? `e${shake}` : 'ok'} role="radiogroup" aria-label={t.size} className={cn('grid grid-cols-4 gap-2 sm:grid-cols-5', tried && !size && 'animate-[kitshop-shake_0.4s_var(--ease-kit)]')}>
                  {sizes.map((s) => {
                    const on = s.label === size;
                    return (
                      <label
                        key={s.label}
                        className={cn(
                          'grid h-11 cursor-pointer place-items-center rounded-lg border text-[14px] font-medium tabular-nums transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
                          on ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-background text-foreground hover:bg-accent',
                        )}
                      >
                        <input type="radio" name={`${id}-s`} value={s.label} checked={on} onChange={() => setSize(s.label)} className="sr-only" />
                        {s.label}
                      </label>
                    );
                  })}
                </div>
                {tried && !size && (
                  <p role="alert" className="mt-2 flex items-center gap-1.5 text-[13px] text-destructive">
                    <Icon name="alert-circle" size={14} />
                    {t.sizeError}
                  </p>
                )}
                {cur?.restock && (
                  <p className="mt-3 flex animate-[kitshop-fade_0.4s] items-center gap-2 text-[13px] text-muted-foreground">
                    <Icon name="calendar" size={14} />
                    {t.expected} <span className="font-medium text-foreground">{cur.restock}</span>
                  </p>
                )}
              </fieldset>
              <div className="mt-6">
                <label htmlFor={`${id}-e`} className="mb-2 block text-[14px] font-medium text-foreground">
                  {t.email}
                </label>
                <input
                  id={`${id}-e`}
                  type="email"
                  autoComplete="email"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  placeholder={t.emailPlaceholder}
                  aria-invalid={(tried && !emailOk) || undefined}
                  aria-describedby={tried && !emailOk ? `${id}-err` : undefined}
                  className={cn(
                    'h-11 w-full rounded-lg border bg-background px-3.5 text-[15px] 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)]',
                    tried && !emailOk ? 'border-destructive' : 'border-input hover:border-border-strong focus:border-border-strong',
                  )}
                />
                {tried && !emailOk && (
                  <p id={`${id}-err`} role="alert" className="mt-2 flex items-center gap-1.5 text-[13px] text-destructive">
                    <Icon name="alert-circle" size={14} />
                    {t.emailError}
                  </p>
                )}
              </div>
              <label className="mt-4 flex min-h-11 cursor-pointer items-center gap-3 text-[14px] text-foreground">
                <input type="checkbox" checked={price} onChange={(e) => setPrice(e.target.checked)} className="peer sr-only" />
                <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-[5px] border transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)]', price ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
                  {price && <Icon name="check" size={13} strokeWidth={2.4} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" />}
                </span>
                {t.alsoPrice}
              </label>
              <Button type="submit" size="lg" className="mt-5 w-full" iconLeft="bell">
                {t.notifyButton}
              </Button>
            </form>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* preorder                                                            */
/* ------------------------------------------------------------------ */

function Preorder(p: StockStatusProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const pre = p.preorder ?? { release: '', reserved: 0, total: 1 };
  const [pay, setPay] = useState<'now' | 'later'>('now');
  const [done, setDone] = useState(false);
  const [ref, inView] = useInView<HTMLDivElement>();
  const share = Math.min(1, pre.reserved / Math.max(1, pre.total));
  const price = p.product?.price ?? 0;
  const deposit = pre.deposit ?? 0;
  const name = useId();
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)] lg:items-center lg:gap-16">
        <Reveal effect="scale" className="relative">
          <ProductMedia className="aspect-[4/3] lg:aspect-[5/6]">{p.product?.image}</ProductMedia>
          <span className="absolute left-4 top-4 inline-flex h-8 items-center gap-1.5 rounded-full bg-primary px-3 text-[12.5px] font-medium text-primary-foreground shadow-[var(--shadow-md)]">
            <Icon name="calendar" size={14} />
            {t.preorderBadge}
          </span>
          {pre.days !== undefined && (
            <span className="absolute bottom-4 left-4 rounded-xl border border-border bg-background/90 px-4 py-2.5 shadow-[var(--shadow-md)] backdrop-blur">
              <span className="block text-[26px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">{formatNumber(locale, pre.days)}</span>
              <span className="mt-1 block text-[12px] text-muted-foreground">{t.daysToGo}</span>
            </span>
          )}
        </Reveal>
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? p.product?.name}</h2>
          {p.description && (
            <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              {p.description}
            </p>
          )}
          <dl className="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-[14px]">
            <div>
              <dt className="text-muted-foreground">{t.releases}</dt>
              <dd className="mt-0.5 font-semibold text-foreground">{pre.release}</dd>
            </div>
            {p.product && (
              <div>
                <dt className="text-muted-foreground">{t.price}</dt>
                <dd className="mt-0.5">
                  <Price value={price} compareAt={p.product.compareAt} currency={currency} />
                </dd>
              </div>
            )}
          </dl>

          <div ref={ref} className="mt-6">
            <p className="flex justify-between gap-3 text-[13px] text-muted-foreground">
              <span>{t.firstBatch}</span>
              <span>
                <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, pre.reserved + (done ? 1 : 0))}</span> {t.of} {formatNumber(locale, pre.total)} {t.reserved}
              </span>
            </p>
            <span className="relative mt-2 block h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
              <span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
            </span>
          </div>

          <fieldset className="mt-7">
            <legend className="sr-only">{t.payNow}</legend>
            <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
              {(['now', 'later'] as const).map((k) => {
                const on = pay === k;
                return (
                  <label
                    key={k}
                    className={cn(
                      'relative flex cursor-pointer flex-col rounded-xl border p-4 transition-[border-color,box-shadow] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
                      on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong',
                    )}
                  >
                    <input type="radio" name={name} value={k} checked={on} onChange={() => setPay(k)} className="sr-only" />
                    <span className="flex items-baseline justify-between gap-3">
                      <span className="text-[14.5px] font-semibold text-foreground">{k === 'now' ? t.payNow : t.payLater}</span>
                      <span className="text-[14px] font-semibold tabular-nums text-foreground">{formatPrice(locale, k === 'now' ? price : deposit, currency)}</span>
                    </span>
                    <span className="mt-1 text-[12.5px] text-muted-foreground">{k === 'now' ? t.payNowNote : `${t.payLaterNote}`}</span>
                    {on && <span aria-hidden className="absolute -right-1.5 -top-1.5 grid size-5 animate-[kitshop-pop_0.35s_var(--ease-kit)] place-items-center rounded-full bg-foreground text-background"><Icon name="check" size={12} strokeWidth={2.4} /></span>}
                  </label>
                );
              })}
            </div>
          </fieldset>

          <Button
            size="lg"
            className="mt-5 w-full"
            iconLeft={done ? 'check' : 'calendar-check'}
            disabled={done}
            onClick={() => {
              setDone(true);
              p.onPreorder?.(pay);
            }}
          >
            <Swap value={done}>{done ? t.preordered : t.preorderButton}</Swap>
          </Button>
          <p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground">
            <Icon name="shield" size={14} />
            {t.cancelAnytime}
          </p>

          {pre.steps?.length ? (
            <Stagger step={80} as="ol" className="mt-8 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border">
              {pre.steps.map((s, i) => (
                <li key={s.label} className="bg-card p-3 sm:p-4">
                  <span className="text-[12px] tabular-nums text-subtle-foreground">{String(i + 1).padStart(2, '0')}</span>
                  <span className="mt-1 block text-[14px] font-medium text-foreground">{s.label}</span>
                  <span className="block text-[12.5px] text-muted-foreground">{s.date}</span>
                </li>
              ))}
            </Stagger>
          ) : null}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* store-availability                                                  */
/* ------------------------------------------------------------------ */

function StoreAvailability(p: StockStatusProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const sizes = p.sizes ?? [];
  const stores = p.stores ?? [];
  const [size, setSize] = useState(p.defaultSize ?? sizes[0]?.label ?? '');
  const firstWith = (s: string) => stores.find((x) => (x.stock[s] ?? 0) > 0)?.id ?? '';
  const [storeId, setStoreId] = useState(firstWith(size));
  const [done, setDone] = useState(false);
  const store = stores.find((s) => s.id === storeId);
  const name = useId();
  const pickSize = (s: string) => {
    setSize(s);
    setDone(false);
    if ((store?.stock[s] ?? 0) <= 0) setStoreId(firstWith(s));
  };
  const count = stores.filter((s) => (s.stock[size] ?? 0) > 0).length;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-16">
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? t.pickup}</h2>
          {p.description && (
            <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              {p.description}
            </p>
          )}
          {p.product && (
            <div className="mt-7 flex items-center gap-4">
              <ProductMedia className="size-16 shrink-0 rounded-lg">{p.product.image}</ProductMedia>
              <div className="min-w-0">
                <p className="truncate text-[15px] font-medium text-foreground">{p.product.name}</p>
                {p.product.variant && <p className="truncate text-[13px] text-muted-foreground">{p.product.variant}</p>}
              </div>
            </div>
          )}
          <fieldset className="mt-6">
            <legend className="mb-3 text-[14px] font-medium text-foreground">{t.size}</legend>
            <div role="radiogroup" aria-label={t.size} className="grid grid-cols-4 gap-2">
              {sizes.map((s) => {
                const on = s.label === size;
                return (
                  <label
                    key={s.label}
                    className={cn(
                      'grid h-11 cursor-pointer place-items-center rounded-lg border text-[14px] font-medium tabular-nums transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
                      on ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-background text-foreground hover:bg-accent',
                    )}
                  >
                    <input type="radio" name={`${name}-size`} value={s.label} checked={on} onChange={() => pickSize(s.label)} className="sr-only" />
                    {s.label}
                  </label>
                );
              })}
            </div>
          </fieldset>
          <p className="mt-4 text-[13.5px] text-muted-foreground" aria-live="polite">
            <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, count)}</span> {t.of} {formatNumber(locale, stores.length)} {t.storesCount} · {t.size} {size} {t.available}
          </p>
        </div>

        <div className="min-w-0">
          <ul key={size} role="radiogroup" aria-label={t.stores} className="grid grid-cols-1 gap-3">
            {stores.map((s, i) => {
              const n = s.stock[size] ?? 0;
              const kind: StockKind = n <= 0 ? 'out' : n === 1 ? 'last' : n <= (p.lowFrom ?? 2) ? 'low' : 'in';
              const on = s.id === storeId && n > 0;
              return (
                <li key={s.id} className="animate-[kitshop-in_0.45s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 60}ms` }}>
                  <label
                    className={cn(
                      'grid cursor-pointer grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 rounded-2xl border p-4 transition-[border-color,box-shadow,background-color] sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center sm:p-5 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
                      n <= 0 ? 'cursor-not-allowed border-border bg-surface' : on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong',
                    )}
                  >
                    <input type="radio" name={`${name}-store`} value={s.id} checked={on} disabled={n <= 0} onChange={() => { setStoreId(s.id); setDone(false); }} className="sr-only" />
                    <span className={cn('grid size-5 place-items-center rounded-full border-2 transition-colors', on ? 'border-foreground' : 'border-border-strong', n <= 0 && 'opacity-40')} aria-hidden>
                      <span className={cn('size-2.5 rounded-full bg-foreground transition-transform duration-300 ease-[var(--ease-kit)]', on ? 'scale-100' : 'scale-0')} />
                    </span>
                    <span className="min-w-0">
                      <span className="flex flex-wrap items-baseline gap-x-2">
                        <span className={cn('text-[15px] font-semibold', n <= 0 ? 'text-muted-foreground' : 'text-foreground')}>{s.name}</span>
                        {s.distance && <span className="text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                      </span>
                      <span className="block truncate text-[13px] text-muted-foreground">{s.address}</span>
                      {s.hours && <span className="block text-[12.5px] text-subtle-foreground">{s.hours}</span>}
                    </span>
                    <span className="col-start-2 sm:col-start-3 sm:text-right">
                      <span className={cn('inline-flex items-center gap-2 text-[13.5px] font-medium', KIND[kind].text)}>
                        <Dot kind={kind} />
                        {n <= 0 ? t.notInStore : n === 1 ? t.onlyOne : `${formatNumber(locale, n)} ${t.inStore}`}
                      </span>
                      {n > 0 && s.ready && <span className="block text-[12.5px] text-muted-foreground sm:text-right">{s.ready}</span>}
                    </span>
                  </label>
                </li>
              );
            })}
          </ul>
          <div className="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <p className="text-[13px] text-muted-foreground" aria-live="polite">
              {done ? (
                <span className="inline-flex items-center gap-1.5 font-medium text-success-foreground">
                  <Icon name="check-circle" size={15} />
                  {t.reservedDone} · {store?.name}
                </span>
              ) : (
                t.reservedNote
              )}
            </p>
            <Button
              iconLeft={done ? 'check' : 'store'}
              disabled={!store || done}
              onClick={() => {
                if (!store) return;
                setDone(true);
                p.onReserve?.(store, size);
              }}
            >
              <Swap value={done}>{done ? t.reservedDone : t.reserve}</Swap>
            </Button>
          </div>
        </div>
      </div>
    </Section>
  );
}

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

/**
 * Stock in five layouts. inline: the stock line on product cards.
 * low-stock: stock per size. back-in-stock: a note when it is back.
 * preorder: order before release. store-availability: pick up today.
 */
export function StockStatus(props: StockStatusProps) {
  const { variant = 'inline' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'low-stock') return <LowStock {...props} t={t} />;
  if (variant === 'back-in-stock') return <BackInStock {...props} t={t} />;
  if (variant === 'preorder') return <Preorder {...props} t={t} />;
  if (variant === 'store-availability') return <StoreAvailability {...props} t={t} />;
  return <Inline {...props} t={t} />;
}

Frequent questions

How do I install Stock status?

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

Which variants does Stock status have?

5 variants, Stock line, Stock per size, Back in stock alert, Preorder and Available in store. You pick one with the variant prop, for example variant="low-stock".

Can I translate the texts of Stock status?

Yes. Stock status carries 48 texts of its own in English and German, such as “Size”, “Pick your size” and “Sold out”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Stock status?

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

Can I adjust Stock status without code?

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

Often searched

Jump to

Copied to the clipboard