Zum Inhalt springen
UI Kit
Accent

Abos

Verkaufen, was wiederkommt. Einmal kaufen oder abonnieren auf der Produktseite, Boxgrößen als Pläne, ein Baukasten in drei Schritten, der Plan der nächsten Lieferungen und wie ein Abo funktioniert.

Eine von 2 Components in Bundles und Abos

5 Variants

Variants

Abonnieren und sparen

Einmal kaufen oder abonnieren auf der Produktseite. Die Abo-Option öffnet mit weicher Höhe und zeigt den Rhythmus mit gleitendem Daumen und die Vorteile, der Preis zählt zum Abo-Preis und der Knopf nennt den Rhythmus.

Im Studio öffnen

Box-Pläne

Boxgrößen als Pläne mit Preis pro Lieferung und pro Stück. Monatlich oder jährlich mit gleitender Pille, die Preise zählen zum neuen Wert, der meistgewählte Plan hat einen leuchtenden Rand und jede Karte hebt sich beim Hover.

Im Studio öffnen

Box zusammenstellen

Drei Schritte mit einem Balken, der sich füllt, für Sorte, Menge und Rhythmus. Jeder Schritt gleitet von seiner Seite herein, die gewählte Kachel zeigt einen Haken, und die Übersicht daneben folgt jeder Wahl und zählt den Preis.

Im Studio öffnen

Lieferplan

Die nächsten Lieferungen auf einer Zeitleiste, die sich beim Scrollen füllt. Geplante Boxen lassen sich aussetzen (durchgestrichen, mit Rückgängig) oder eine Woche schieben, die nächste ist markiert, und Pausieren dimmt den Plan und sagt es.

Im Studio öffnen

So funktioniert es

Wie ein Abo funktioniert, in vier Schritten. Die Schritte steigen nacheinander auf, eine Linie verbindet sie und füllt sich beim Scrollen, quer auf dem Desktop und als Liste auf Telefon und Tablet.

Im Studio öffnen

Details

Was Abos mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/subscription-box.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 40 eigene Texte

Props

Die Schnittstelle SubscriptionBoxProps aus components/ui/subscription-box.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    SubscriptionBoxVariant

    purchase-toggleplansbuilderschedulehow-it-works

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • productoptional
    { name: string; subtitle?: string; price: number; image?: React.ReactNode }

    purchase-toggle: the product

  • discountoptional
    number

    Discount in percent for subscribers

  • rhythmsoptional
    number[]

    Delivery rhythms in weeks

  • defaultRhythmoptional
    number
  • plansoptional
    SubPlan[]
  • unitOneoptional
    string

    plans: one piece, e.g. "bag", shown as "€12.45 per bag"

  • yearlyoptional
    number

    plans: yearly discount in percent on top

  • questionsoptional
    [string, string, string]

    builder: the question of each step, e.g. ["Which coffee?", "How much?", "How often?"]

  • choicesoptional
    SubOption[]

    builder: the choices of step one, two and three

  • sizesoptional
    (SubOption & { price: number })[]
  • deliveriesoptional
    SubDelivery[]
  • stepsoptional
    { icon?: IconName; title: string; text: string }[]
  • perksoptional
    string[]
  • currencyoptional
    string
  • onSubscribeoptional
    (detail: Record<string, string | number>) => void

Eingebaute Texte

Diese Texte schreibt Abos selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 40 Texte anzeigen
oneTimeEnglisch One-time purchaseDeutsch Einmal kaufen
subscribeEnglisch Subscribe and saveDeutsch Abonnieren und sparen
everyEnglisch EveryDeutsch Alle
everyWeekEnglisch Every weekDeutsch Jede Woche
weekEnglisch weekDeutsch Woche
weeksEnglisch weeksDeutsch Wochen
deliverEveryEnglisch Deliver everyDeutsch Liefern alle
perDeliveryEnglisch per deliveryDeutsch pro Lieferung
perPieceEnglisch per pieceDeutsch pro Stück
perEnglisch perDeutsch pro
saveEnglisch SaveDeutsch Spare
addToCartEnglisch Add to cartDeutsch In den Warenkorb
startSubEnglisch Start subscriptionDeutsch Abo starten
startedEnglisch Subscription startedDeutsch Abo gestartet
cancelAnyEnglisch Skip, pause or cancel any time in your account.Deutsch Jederzeit im Konto aussetzen, pausieren oder kündigen.
monthlyEnglisch MonthlyDeutsch Monatlich
yearlyEnglisch YearlyDeutsch Jährlich
billedEnglisch billed yearlyDeutsch jährlich abgerechnet
chooseEnglisch ChooseDeutsch Auswählen
chosenEnglisch ChosenDeutsch Ausgewählt
stepEnglisch StepDeutsch Schritt
ofEnglisch ofDeutsch von
nextEnglisch NextDeutsch Weiter
backEnglisch BackDeutsch Zurück
summaryEnglisch Your boxDeutsch Deine Box
whatEnglisch WhatDeutsch Was
howMuchEnglisch How muchDeutsch Wie viel
howOftenEnglisch How oftenDeutsch Wie oft
thenEnglisch thenDeutsch danach
shippedEnglisch ShippedDeutsch Versendet
upNextEnglisch Up nextDeutsch Als Nächstes
plannedEnglisch PlannedDeutsch Geplant
skippedEnglisch SkippedDeutsch Ausgesetzt
skipEnglisch SkipDeutsch Aussetzen
undoEnglisch Undo skipDeutsch Doch liefern
moveEnglisch Move a weekDeutsch Eine Woche später
movedEnglisch MovedDeutsch Verschoben
pauseEnglisch Pause subscriptionDeutsch Abo pausieren
resumeEnglisch Resume subscriptionDeutsch Abo fortsetzen
pausedEnglisch Your subscription is paused. Nothing ships until you resume.Deutsch Dein Abo ist pausiert. Bis du es fortsetzt, wird nichts verschickt.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-checked, aria-current, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 649 Zeilen
components/ui/subscription-box.tsx
'use client';

import { useId, useState } from 'react';
import { cn, round, 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 { ProductMedia, ShopMotion, Swap, TweenPrice, formatNumber, formatPercent, formatPrice } from '@/components/ui/product-image';

/*
 * Selling something that comes again: coffee, tea, flowers, razors, pet food.
 *
 *   purchase-toggle  one-time or subscribe and save on the product page, with the rhythm
 *   plans            box sizes as plans, monthly or yearly, the price per delivery and per piece
 *   builder          three steps: what, how much, how often, with a summary that follows along
 *   schedule         the next deliveries on a timeline: skip, move, pause
 *   how-it-works     how a subscription works, in steps that light up while scrolling
 */

export type SubscriptionBoxVariant = 'purchase-toggle' | 'plans' | 'builder' | 'schedule' | 'how-it-works';

export interface SubPlan {
  id: string;
  name: string;
  /** e.g. "2 bags of 250 g" */
  amount: string;
  /** Price per delivery */
  price: number;
  /** Pieces per delivery, for the price per piece */
  pieces?: number;
  perks?: string[];
  tag?: string;
}

export interface SubOption {
  id: string;
  label: string;
  note?: string;
  icon?: IconName;
  image?: React.ReactNode;
}

export interface SubDelivery {
  id: string;
  /** Already formatted, e.g. "Tue, Nov 4" */
  date: string;
  /** What comes, e.g. "Nº 7 espresso · 2 × 250 g" */
  items: string;
  status?: 'shipped' | 'next' | 'planned' | 'skipped';
}

export interface SubscriptionBoxProps {
  variant?: SubscriptionBoxVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** purchase-toggle: the product */
  product?: { name: string; subtitle?: string; price: number; image?: React.ReactNode };
  /** Discount in percent for subscribers */
  discount?: number;
  /** Delivery rhythms in weeks */
  rhythms?: number[];
  defaultRhythm?: number;
  plans?: SubPlan[];
  /** plans: one piece, e.g. "bag", shown as "€12.45 per bag" */
  unitOne?: string;
  /** plans: yearly discount in percent on top */
  yearly?: number;
  /** builder: the question of each step, e.g. ["Which coffee?", "How much?", "How often?"] */
  questions?: [string, string, string];
  /** builder: the choices of step one, two and three */
  choices?: SubOption[];
  sizes?: (SubOption & { price: number })[];
  deliveries?: SubDelivery[];
  steps?: { icon?: IconName; title: string; text: string }[];
  perks?: string[];
  currency?: string;
  onSubscribe?: (detail: Record<string, string | number>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    oneTime: 'One-time purchase',
    subscribe: 'Subscribe and save',
    every: 'Every',
    everyWeek: 'Every week',
    week: 'week',
    weeks: 'weeks',
    deliverEvery: 'Deliver every',
    perDelivery: 'per delivery',
    perPiece: 'per piece',
    per: 'per',
    save: 'Save',
    addToCart: 'Add to cart',
    startSub: 'Start subscription',
    started: 'Subscription started',
    cancelAny: 'Skip, pause or cancel any time in your account.',
    monthly: 'Monthly',
    yearly: 'Yearly',
    billed: 'billed yearly',
    choose: 'Choose',
    chosen: 'Chosen',
    step: 'Step',
    of: 'of',
    next: 'Next',
    back: 'Back',
    summary: 'Your box',
    what: 'What',
    howMuch: 'How much',
    howOften: 'How often',
    then: 'then',
    shipped: 'Shipped',
    upNext: 'Up next',
    planned: 'Planned',
    skipped: 'Skipped',
    skip: 'Skip',
    undo: 'Undo skip',
    move: 'Move a week',
    moved: 'Moved',
    pause: 'Pause subscription',
    resume: 'Resume subscription',
    paused: 'Your subscription is paused. Nothing ships until you resume.',
  },
  de: {
    oneTime: 'Einmal kaufen',
    subscribe: 'Abonnieren und sparen',
    every: 'Alle',
    everyWeek: 'Jede Woche',
    week: 'Woche',
    weeks: 'Wochen',
    deliverEvery: 'Liefern alle',
    perDelivery: 'pro Lieferung',
    perPiece: 'pro Stück',
    per: 'pro',
    save: 'Spare',
    addToCart: 'In den Warenkorb',
    startSub: 'Abo starten',
    started: 'Abo gestartet',
    cancelAny: 'Jederzeit im Konto aussetzen, pausieren oder kündigen.',
    monthly: 'Monatlich',
    yearly: 'Jährlich',
    billed: 'jährlich abgerechnet',
    choose: 'Auswählen',
    chosen: 'Ausgewählt',
    step: 'Schritt',
    of: 'von',
    next: 'Weiter',
    back: 'Zurück',
    summary: 'Deine Box',
    what: 'Was',
    howMuch: 'Wie viel',
    howOften: 'Wie oft',
    then: 'danach',
    shipped: 'Versendet',
    upNext: 'Als Nächstes',
    planned: 'Geplant',
    skipped: 'Ausgesetzt',
    skip: 'Aussetzen',
    undo: 'Doch liefern',
    move: 'Eine Woche später',
    moved: 'Verschoben',
    pause: 'Abo pausieren',
    resume: 'Abo fortsetzen',
    paused: 'Dein Abo ist pausiert. Bis du es fortsetzt, wird nichts verschickt.',
  },
};

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

function Head({ eyebrow, title, description, center, aside }: { eyebrow?: string; title?: string; description?: string; center?: boolean; aside?: React.ReactNode }) {
  if (!title) return null;
  return (
    <div className={cn('mb-10 flex flex-col gap-6 sm:mb-12', center ? 'items-center text-center' : 'sm:flex-row sm:items-end sm:justify-between')}>
      <div className={cn('max-w-[40rem]', center && 'mx-auto')}>
        {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>
      {aside}
    </div>
  );
}

const weeksLabel = (t: Texts, n: number) => (n === 1 ? t.everyWeek : `${t.every} ${n} ${t.weeks}`);

/** Rhythm chips with a sliding thumb */
function Rhythm({ rhythms, value, onChange, t, name }: { rhythms: number[]; value: number; onChange: (n: number) => void; t: Texts; name: string }) {
  const i = Math.max(0, rhythms.indexOf(value));
  return (
    <div role="radiogroup" aria-label={t.deliverEvery} className="relative grid rounded-lg border border-border-strong bg-surface p-1" style={{ gridTemplateColumns: `repeat(${rhythms.length}, minmax(0, 1fr))` }}>
      <span aria-hidden className="absolute inset-y-1 left-1 rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `calc((100% - 0.5rem) / ${rhythms.length})`, transform: `translateX(${i * 100}%)` }} />
      {rhythms.map((n) => (
        <label key={n} className={cn('relative z-10 grid min-h-11 cursor-pointer place-items-center rounded-md px-1 text-center text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', n === value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
          <input type="radio" name={name} checked={n === value} onChange={() => onChange(n)} className="sr-only" />
          {n} {n === 1 ? t.week : t.weeks}
        </label>
      ))}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* purchase-toggle                                                     */
/* ------------------------------------------------------------------ */

function PurchaseToggle(p: SubscriptionBoxProps & { t: Texts }) {
  const { t, currency, product } = p;
  const locale = useLocale();
  const rhythms = p.rhythms ?? [2, 4, 6];
  const [mode, setMode] = useState<'once' | 'sub'>('sub');
  const [rhythm, setRhythm] = useState(p.defaultRhythm ?? rhythms[0]);
  const [done, setDone] = useState(false);
  const name = useId();
  const price = product?.price ?? 0;
  const subPrice = round(price * (1 - (p.discount ?? 0) / 100));
  const shown = mode === 'sub' ? subPrice : price;
  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">{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 ?? product?.name}</h2>
          {product?.subtitle && <p className="kit-rise mt-2 text-[15px] text-muted-foreground">{product.subtitle}</p>}
          <div className="mt-5 flex items-baseline gap-3">
            <TweenPrice value={shown} currency={currency} className="text-[28px] font-semibold tracking-[-0.02em] text-foreground" />
            {mode === 'sub' && (
              <s key="was" className="animate-[kitshop-fade_0.3s] text-[15px] tabular-nums text-muted-foreground">
                {formatPrice(locale, price, currency)}
              </s>
            )}
          </div>

          <fieldset className="mt-7 space-y-2">
            <legend className="sr-only">{t.subscribe}</legend>
            {(['sub', 'once'] as const).map((m) => {
              const on = mode === m;
              return (
                <div key={m} className={cn('rounded-xl border transition-[border-color,box-shadow] duration-300', on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong')}>
                  <label className="flex min-h-14 cursor-pointer items-center gap-3 px-4 py-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]">
                    <input type="radio" name={name} checked={on} onChange={() => setMode(m)} className="sr-only" />
                    <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors', on ? 'border-foreground' : 'border-border-strong')}>
                      <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="flex-1 text-[15px] font-medium text-foreground">{m === 'sub' ? t.subscribe : t.oneTime}</span>
                    {m === 'sub' && p.discount ? <span className={cn('rounded-full px-2 py-0.5 text-[12.5px] font-semibold', 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]', SAVE)}>-{formatPercent(locale, p.discount)}</span> : null}
                    <span className="text-[14.5px] font-semibold tabular-nums text-foreground">{formatPrice(locale, m === 'sub' ? subPrice : price, currency)}</span>
                  </label>
                  {m === 'sub' && (
                    <div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                      <div className="overflow-hidden" inert={!on}>
                        <div className="space-y-3 px-4 pb-4">
                          <p className="text-[13px] text-muted-foreground">{t.deliverEvery}</p>
                          <Rhythm rhythms={rhythms} value={rhythm} onChange={setRhythm} t={t} name={`${name}-r`} />
                          {p.perks?.length ? (
                            <ul className="grid grid-cols-1 gap-1.5 pt-1 sm:grid-cols-2">
                              {p.perks.map((perk) => (
                                <li key={perk} className="flex items-start gap-2 text-[13px] text-foreground">
                                  <Icon name="check" size={14} className={cn('mt-0.5 shrink-0', SAVE)} />
                                  {perk}
                                </li>
                              ))}
                            </ul>
                          ) : null}
                        </div>
                      </div>
                    </div>
                  )}
                </div>
              );
            })}
          </fieldset>
          <Button
            size="lg"
            className="mt-6 w-full"
            iconLeft={done ? 'check' : mode === 'sub' ? 'repeat' : 'bag'}
            onClick={() => {
              setDone(true);
              p.onSubscribe?.({ mode, rhythm, price: shown });
            }}
          >
            <Swap value={`${mode}-${done}`}>{done ? (mode === 'sub' ? t.started : t.addToCart) : mode === 'sub' ? `${t.startSub} · ${weeksLabel(t, rhythm)}` : t.addToCart}</Swap>
          </Button>
          {mode === 'sub' && <p className="mt-3 animate-[kitshop-fade_0.3s] text-center text-[13px] text-muted-foreground">{t.cancelAny}</p>}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* plans                                                               */
/* ------------------------------------------------------------------ */

function Plans(p: SubscriptionBoxProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const [period, setPeriod] = useState<'month' | 'year'>('month');
  const [picked, setPicked] = useState<string | null>(null);
  const factor = period === 'year' ? 1 - (p.yearly ?? 0) / 100 : 1;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head
        eyebrow={p.eyebrow}
        title={p.title}
        description={p.description}
        center
        aside={
          p.yearly ? (
            <div role="radiogroup" aria-label={`${t.monthly}, ${t.yearly}`} className="relative grid w-[19rem] grid-cols-2 rounded-full border border-border-strong bg-surface p-1">
              <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-foreground transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${period === 'year' ? 100 : 0}%)` }} />
              {(['month', 'year'] as const).map((k) => (
                <button key={k} type="button" role="radio" aria-checked={period === k} onClick={() => setPeriod(k)} className={cn('relative z-10 inline-flex h-10 items-center justify-center gap-1.5 rounded-full text-[13.5px] font-medium transition-colors', period === k ? 'text-background' : 'text-muted-foreground hover:text-foreground', FOCUS)}>
                  {k === 'month' ? t.monthly : t.yearly}
                  {k === 'year' && <span className={cn('text-[11.5px] font-semibold', period === k ? 'opacity-80' : SAVE)}>-{formatPercent(locale, p.yearly!)}</span>}
                </button>
              ))}
            </div>
          ) : null
        }
      />
      <Stagger step={90} as="ul" className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:gap-6">
        {(p.plans ?? []).map((plan) => {
          const price = round(plan.price * factor);
          const on = picked === plan.id;
          const hero = !!plan.tag;
          return (
            <li key={plan.id}>
              <article className={cn(CARD, 'relative flex h-full flex-col p-6 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 motion-reduce:transition-none', hero && 'shadow-[var(--highlight),0_0_0_1.5px_var(--primary),var(--shadow-md)]')}>
                {plan.tag && <span className="absolute -top-3 left-6 rounded-full bg-primary px-2.5 py-1 text-[12px] font-medium text-primary-foreground shadow-[var(--shadow-sm)]">{plan.tag}</span>}
                <h3 className="text-[19px] font-semibold tracking-[-0.015em] text-foreground">{plan.name}</h3>
                <p className="mt-1 text-[14px] text-muted-foreground">{plan.amount}</p>
                <TweenPrice value={price} currency={currency} className="mt-6 block text-[32px] font-semibold tracking-[-0.03em] text-foreground" />
                <p className="mt-1 text-[13px] text-muted-foreground">
                  {t.perDelivery}
                  {plan.pieces ? (
                    <>
                      {' · '}
                      <span className="tabular-nums">{formatPrice(locale, price / plan.pieces, currency)}</span> {p.unitOne ? `${t.per} ${p.unitOne}` : t.perPiece}
                    </>
                  ) : null}
                </p>
                <p className={cn('h-5 text-[12.5px] transition-opacity duration-300', SAVE, period === 'year' ? 'opacity-100' : 'opacity-0')} aria-hidden={period !== 'year'}>
                  {t.billed}
                </p>
                {plan.perks?.length ? (
                  <ul className="mt-6 space-y-2.5 border-t border-border pt-6">
                    {plan.perks.map((perk) => (
                      <li key={perk} className="flex items-start gap-2.5 text-[14px] text-foreground">
                        <Icon name="check" size={16} className={cn('mt-px shrink-0', ACCENT_TEXT)} />
                        {perk}
                      </li>
                    ))}
                  </ul>
                ) : null}
                <div className="mt-auto pt-7">
                  <Button className="w-full" variant={hero || on ? 'primary' : 'outline'} iconLeft={on ? 'check' : undefined} onClick={() => { setPicked(plan.id); p.onSubscribe?.({ plan: plan.id, period, price }); }}>
                    <Swap value={on}>{on ? t.chosen : `${t.choose} · ${plan.name}`}</Swap>
                  </Button>
                </div>
              </article>
            </li>
          );
        })}
      </Stagger>
      <p className="mt-8 text-center text-[13.5px] text-muted-foreground">{t.cancelAny}</p>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* builder                                                             */
/* ------------------------------------------------------------------ */

function Builder(p: SubscriptionBoxProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const choices = p.choices ?? [];
  const sizes = p.sizes ?? [];
  const rhythms = p.rhythms ?? [2, 4];
  const [step, setStep] = useState(0);
  const [choice, setChoice] = useState(choices[0]?.id ?? '');
  const [size, setSize] = useState(sizes[1]?.id ?? sizes[0]?.id ?? '');
  const [rhythm, setRhythm] = useState(p.defaultRhythm ?? rhythms[0]);
  const [done, setDone] = useState(false);
  const [dir, setDir] = useState<1 | -1>(1);
  const name = useId();
  const c = choices.find((x) => x.id === choice);
  const s = sizes.find((x) => x.id === size);
  const price = s ? round(s.price * (1 - (p.discount ?? 0) / 100)) : 0;
  const titles = [t.what, t.howMuch, t.howOften];
  const go = (n: number) => {
    setDir(n > step ? 1 : -1);
    setStep(n);
  };
  const tile = (on: boolean) => cn('relative flex h-full cursor-pointer flex-col rounded-xl border p-4 max-sm:min-h-16 max-sm:justify-center max-sm:pe-10 transition-[border-color,box-shadow,transform] duration-300 hover:-translate-y-0.5 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');
  const checkMark = <span aria-hidden className="absolute right-3 top-3 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>;
  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">
          {/* Steps as a bar that fills, each step a button */}
          <ol className="grid grid-cols-3 gap-2">
            {titles.map((x, i) => (
              <li key={x}>
                <button type="button" onClick={() => go(i)} aria-current={step === i ? 'step' : undefined} className={cn('group/st relative block w-full after:absolute after:inset-x-0 after:-inset-y-2 after:content-[""] text-left', FOCUS)}>
                  <span className="relative block h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
                    <span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${i <= step ? 1 : 0})` }} />
                  </span>
                  <span className={cn('mt-2 block text-[12.5px]', i === step ? 'font-semibold text-foreground' : 'text-muted-foreground group-hover/st:text-foreground')}>
                    <span className="tabular-nums">{i + 1}</span> · {x}
                  </span>
                </button>
              </li>
            ))}
          </ol>
          <div key={step} className={cn('mt-8', dir === 1 ? 'animate-[kitshop-next_0.45s_var(--ease-kit)]' : 'animate-[kitshop-prev_0.45s_var(--ease-kit)]')}>
            <h3 className="text-[18px] font-semibold text-foreground">{p.questions?.[step] ?? titles[step]}</h3>
            {step === 0 && (
              <div role="radiogroup" aria-label={t.what} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
                {choices.map((x) => (
                  <label key={x.id} className={tile(x.id === choice)}>
                    <input type="radio" name={`${name}-c`} checked={x.id === choice} onChange={() => setChoice(x.id)} className="sr-only" />
                    {x.image ? <ProductMedia className="mb-3 aspect-[4/3] rounded-lg max-sm:absolute max-sm:left-3 max-sm:top-1/2 max-sm:mb-0 max-sm:size-14 max-sm:-translate-y-1/2 max-sm:aspect-square">{x.image}</ProductMedia> : <Icon name={x.icon ?? 'coffee'} size={22} className="mb-3 text-foreground" />}
                    <span className={cn('text-[15px] font-semibold text-foreground', x.image && 'max-sm:ps-[4.25rem]')}>{x.label}</span>
                    {x.note && <span className={cn('mt-0.5 text-[13px] leading-snug text-muted-foreground', x.image && 'max-sm:ps-[4.25rem]')}>{x.note}</span>}
                    {x.id === choice && checkMark}
                  </label>
                ))}
              </div>
            )}
            {step === 1 && (
              <div role="radiogroup" aria-label={t.howMuch} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
                {sizes.map((x) => (
                  <label key={x.id} className={tile(x.id === size)}>
                    <input type="radio" name={`${name}-s`} checked={x.id === size} onChange={() => setSize(x.id)} className="sr-only" />
                    <span className="text-[15px] font-semibold text-foreground">{x.label}</span>
                    {x.note && <span className="mt-0.5 text-[13px] text-muted-foreground">{x.note}</span>}
                    <span className="mt-4 text-[17px] font-semibold tabular-nums text-foreground">{formatPrice(locale, round(x.price * (1 - (p.discount ?? 0) / 100)), currency)}</span>
                    {x.id === size && checkMark}
                  </label>
                ))}
              </div>
            )}
            {step === 2 && (
              <div className="mt-4 max-w-[30rem]">
                <Rhythm rhythms={rhythms} value={rhythm} onChange={setRhythm} t={t} name={`${name}-r`} />
                <p className="mt-3 text-[13.5px] text-muted-foreground">{t.cancelAny}</p>
              </div>
            )}
          </div>
          <div className="mt-8 flex gap-2">
            {step > 0 && (
              <Button variant="ghost" iconLeft="arrow-left" onClick={() => go(step - 1)}>
                {t.back}
              </Button>
            )}
            {step < 2 && (
              <Button variant="outline" icon="arrow-right" onClick={() => go(step + 1)}>
                {t.next}
              </Button>
            )}
          </div>
        </div>

        <aside className="lg:sticky lg:top-8 lg:self-start" aria-label={t.summary}>
          <div className={cn(CARD, 'p-5 sm:p-6')}>
            <p className="text-[13px] font-medium text-muted-foreground">{t.summary}</p>
            <dl className="mt-3 divide-y divide-border text-[14px]">
              {[
                [t.what, c?.label],
                [t.howMuch, s?.label],
                [t.howOften, weeksLabel(t, rhythm)],
              ].map(([k, v], i) => (
                <div key={k} className="flex items-baseline justify-between gap-3 py-2.5">
                  <dt className="text-muted-foreground">{k}</dt>
                  <dd className={cn('text-right font-medium text-foreground', i === step && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
                    <Swap value={v ?? ''}>{v}</Swap>
                  </dd>
                </div>
              ))}
            </dl>
            <div className="mt-2 flex items-baseline justify-between gap-3 border-t border-border pt-4">
              <span className="text-[14px] text-muted-foreground">{t.perDelivery}</span>
              <TweenPrice value={price} currency={currency} className="text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
            </div>
            <Button size="lg" className="mt-5 w-full" iconLeft={done ? 'check' : 'repeat'} onClick={() => { setDone(true); p.onSubscribe?.({ choice, size, rhythm, price }); }}>
              <Swap value={done}>{done ? t.started : t.startSub}</Swap>
            </Button>
            <p className="mt-2 text-center text-[12.5px] text-muted-foreground">{t.cancelAny}</p>
          </div>
        </aside>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* schedule                                                            */
/* ------------------------------------------------------------------ */

function Schedule(p: SubscriptionBoxProps & { t: Texts }) {
  const { t } = p;
  const [list, setList] = useState<SubDelivery[]>(p.deliveries ?? []);
  const [moved, setMoved] = useState<Record<string, boolean>>({});
  const [paused, setPaused] = useState(false);
  const line = useScrollProgress<HTMLOListElement>({ start: 0.1, end: 0.6 });
  const toggleSkip = (id: string) => setList((l) => l.map((d) => (d.id === id ? { ...d, status: d.status === 'skipped' ? 'planned' : 'skipped' } : d)));
  const STATE: Record<NonNullable<SubDelivery['status']>, { label: string; cls: string; icon: IconName }> = {
    shipped: { label: t.shipped, cls: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground', icon: 'truck' },
    next: { label: t.upNext, cls: cn(ACCENT_SOFT, ACCENT_TEXT), icon: 'package' },
    planned: { label: t.planned, cls: 'bg-muted text-muted-foreground', icon: 'calendar' },
    skipped: { label: t.skipped, cls: 'bg-muted text-subtle-foreground', icon: 'ban' },
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head
        eyebrow={p.eyebrow}
        title={p.title}
        description={p.description}
        aside={
          <Button variant="outline" iconLeft={paused ? 'play' : 'pause'} aria-pressed={paused} onClick={() => setPaused(!paused)}>
            <Swap value={paused}>{paused ? t.resume : t.pause}</Swap>
          </Button>
        }
      />
      {paused && (
        <p className="mb-6 flex animate-[kitshop-in_0.35s_var(--ease-kit)] items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--warning)_14%,transparent)] px-4 py-3 text-[14px] font-medium text-warning-foreground" role="status">
          <Icon name="pause" size={16} />
          {t.paused}
        </p>
      )}
      <ol ref={line} className={cn('relative transition-opacity duration-500', paused && 'opacity-50')}>
        <span aria-hidden className="absolute bottom-6 left-[21px] top-6 w-0.5 rounded-full bg-border sm:left-[23px]" />
        <span aria-hidden className="absolute left-[21px] top-6 h-[calc(100%-3rem)] w-0.5 origin-top rounded-full bg-primary sm:left-[23px]" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
        {list.map((d, i) => {
          const st = STATE[d.status ?? 'planned'];
          const editable = d.status !== 'shipped';
          return (
            <li key={d.id} className="relative flex gap-4 pb-6 last:pb-0 sm:gap-6">
              <span className={cn('relative z-10 grid size-11 shrink-0 place-items-center rounded-full border-4 border-background sm:size-12', d.status === 'next' ? 'bg-primary text-primary-foreground' : d.status === 'shipped' ? 'bg-success text-[oklch(0.99_0_0)]' : 'bg-muted text-muted-foreground')}>
                <Icon name={st.icon} size={18} />
              </span>
              <Reveal effect="rise" delay={i * 70} className={cn(CARD, 'min-w-0 flex-1 p-4 sm:p-5', d.status === 'skipped' && 'bg-surface shadow-none')}>
                <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
                  <div className="min-w-0">
                    <p className={cn('text-[16px] font-semibold text-foreground', d.status === 'skipped' && 'text-muted-foreground line-through decoration-1')}>
                      <Swap value={`${d.date}-${moved[d.id] ? 1 : 0}`}>{d.date}</Swap>
                    </p>
                    <p className="text-[13.5px] text-muted-foreground">{d.items}</p>
                  </div>
                  <span className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium', st.cls)}>{moved[d.id] && d.status !== 'skipped' ? t.moved : st.label}</span>
                </div>
                {editable && (
                  <div className="mt-3 flex flex-wrap gap-1 border-t border-border pt-3">
                    <Button size="sm" variant="ghost" className="h-11 sm:h-9" iconLeft={d.status === 'skipped' ? 'undo' : 'skip-forward'} onClick={() => toggleSkip(d.id)} disabled={paused}>
                      {d.status === 'skipped' ? t.undo : t.skip}
                    </Button>
                    {d.status !== 'skipped' && (
                      <Button size="sm" variant="ghost" className="h-11 sm:h-9" iconLeft="calendar" disabled={paused || moved[d.id]} onClick={() => setMoved((m) => ({ ...m, [d.id]: true }))}>
                        {t.move}
                      </Button>
                    )}
                  </div>
                )}
              </Reveal>
            </li>
          );
        })}
      </ol>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* how-it-works                                                        */
/* ------------------------------------------------------------------ */

function HowItWorks(p: SubscriptionBoxProps & { t: Texts }) {
  const steps = p.steps ?? [];
  const line = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.6 });
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} center />
      <div ref={line} className="relative">
        {/* The line that joins the steps fills while scrolling: across on desktop, down on phones */}
        <span aria-hidden className="absolute hidden h-0.5 rounded-full bg-border lg:block" style={{ top: 31, left: `${round(50 / Math.max(1, steps.length), 2)}%`, right: `${round(50 / Math.max(1, steps.length), 2)}%` }} />
        <span aria-hidden className="absolute hidden h-0.5 origin-left rounded-full bg-primary lg:block" style={{ top: 31, left: `${round(50 / Math.max(1, steps.length), 2)}%`, right: `${round(50 / Math.max(1, steps.length), 2)}%`, transform: 'scaleX(var(--kit-progress, 1))' }} />
        <span aria-hidden className="absolute bottom-8 left-[31px] top-8 w-0.5 rounded-full bg-border sm:hidden" />
        <span aria-hidden className="absolute left-[31px] top-8 h-[calc(100%-4rem)] w-0.5 origin-top rounded-full bg-primary sm:hidden" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
        <ol className={cn('grid grid-cols-1 gap-8 sm:grid-cols-2 lg:gap-6', steps.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
          {steps.map((s, i) => (
            <Reveal as="li" key={s.title} effect="rise" delay={i * 110} className="relative flex gap-4 lg:flex-col lg:items-center lg:text-center">
              <span className="relative z-10 grid size-16 shrink-0 place-items-center rounded-2xl border-4 border-background bg-card text-foreground shadow-[var(--highlight),0_0_0_1px_var(--border),var(--shadow-sm)]">
                <Icon name={s.icon ?? 'package'} size={24} />
                <span className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-primary text-[11.5px] font-semibold tabular-nums text-primary-foreground">{i + 1}</span>
              </span>
              <span className="lg:mt-5">
                <span className="block text-[17px] font-semibold text-foreground">{s.title}</span>
                <span className="mt-1.5 block text-pretty text-[14.5px] leading-relaxed text-muted-foreground lg:mx-auto lg:max-w-[16rem]">{s.text}</span>
              </span>
            </Reveal>
          ))}
        </ol>
      </div>
    </Section>
  );
}

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

/**
 * Subscriptions in five layouts. purchase-toggle: one-time or subscribe on
 * the product page. plans: box sizes. builder: three steps to a box.
 * schedule: the next deliveries. how-it-works: the steps.
 */
export function SubscriptionBox(props: SubscriptionBoxProps) {
  const { variant = 'purchase-toggle' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'plans') return <Plans {...props} t={t} />;
  if (variant === 'builder') return <Builder {...props} t={t} />;
  if (variant === 'schedule') return <Schedule {...props} t={t} />;
  if (variant === 'how-it-works') return <HowItWorks {...props} t={t} />;
  return <PurchaseToggle {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Abos?

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

Welche Varianten hat Abos?

5 Varianten, Abonnieren und sparen, Box-Pläne, Box zusammenstellen, Lieferplan und So funktioniert es. Du wählst sie mit der Prop variant, zum Beispiel variant="plans".

Kann ich die Texte von Abos übersetzen?

Ja. Abos bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Einmal kaufen“, „Abonnieren und sparen“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Abos einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Abos mit aiw_components und installieren sie mit aiw_add @ai-website/subscription-box. Nenn im Prompt die Variante, etwa variant="purchase-toggle", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Abos ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SubscriptionBox-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert