Skip to content
UI Kit
Accent color

Subscriptions

Selling something that comes again. One-time or subscribe on the product page, box sizes as plans, a three step builder, the schedule of the next deliveries and how a subscription works.

One of 2 components in Bundles and subscriptions

5 variants

Variants

Subscribe and save

One-time or subscribe on the product page. The subscribe option opens with a soft height and shows the rhythm with a sliding thumb and the perks, the price counts to the subscriber price and the button names the rhythm.

Open in Studio

Box plans

Box sizes as plans with price per delivery and per piece. Monthly or yearly with a sliding pill, prices count to the new value, the plan people choose most has a lit border and every card lifts on hover.

Open in Studio

Box builder

Three steps with a bar that fills, for which coffee, how much and how often. Each step slides in from its side, the picked tile pops a check, and the summary beside it follows every choice and counts the price.

Open in Studio

Delivery schedule

The next deliveries on a timeline that fills while scrolling. Planned boxes can be skipped (struck through, with undo) or moved a week, the next one is marked, and pausing dims the plan and says so.

Open in Studio

How it works

How a subscription works in four steps. The steps rise one after another, a line joins them and fills while scrolling, across on desktop and as a list on phones and tablets.

Open in Studio

Details

What Subscriptions brings along, read from the registry and the source.

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

Props

The SubscriptionBoxProps interface from components/ui/subscription-box.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 40 texts
oneTimeEnglish One-time purchaseGerman Einmal kaufen
subscribeEnglish Subscribe and saveGerman Abonnieren und sparen
everyEnglish EveryGerman Alle
everyWeekEnglish Every weekGerman Jede Woche
weekEnglish weekGerman Woche
weeksEnglish weeksGerman Wochen
deliverEveryEnglish Deliver everyGerman Liefern alle
perDeliveryEnglish per deliveryGerman pro Lieferung
perPieceEnglish per pieceGerman pro Stück
perEnglish perGerman pro
saveEnglish SaveGerman Spare
addToCartEnglish Add to cartGerman In den Warenkorb
startSubEnglish Start subscriptionGerman Abo starten
startedEnglish Subscription startedGerman Abo gestartet
cancelAnyEnglish Skip, pause or cancel any time in your account.German Jederzeit im Konto aussetzen, pausieren oder kündigen.
monthlyEnglish MonthlyGerman Monatlich
yearlyEnglish YearlyGerman Jährlich
billedEnglish billed yearlyGerman jährlich abgerechnet
chooseEnglish ChooseGerman Auswählen
chosenEnglish ChosenGerman Ausgewählt
stepEnglish StepGerman Schritt
ofEnglish ofGerman von
nextEnglish NextGerman Weiter
backEnglish BackGerman Zurück
summaryEnglish Your boxGerman Deine Box
whatEnglish WhatGerman Was
howMuchEnglish How muchGerman Wie viel
howOftenEnglish How oftenGerman Wie oft
thenEnglish thenGerman danach
shippedEnglish ShippedGerman Versendet
upNextEnglish Up nextGerman Als Nächstes
plannedEnglish PlannedGerman Geplant
skippedEnglish SkippedGerman Ausgesetzt
skipEnglish SkipGerman Aussetzen
undoEnglish Undo skipGerman Doch liefern
moveEnglish Move a weekGerman Eine Woche später
movedEnglish MovedGerman Verschoben
pauseEnglish Pause subscriptionGerman Abo pausieren
resumeEnglish Resume subscriptionGerman Abo fortsetzen
pausedEnglish Your subscription is paused. Nothing ships until you resume.German Dein Abo ist pausiert. Bis du es fortsetzt, wird nichts verschickt.

Accessibility

Read from the source of the component.

  • Sets aria-checked, aria-current, aria-label and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles radio, 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. 4 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, 649 lines
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} />;
}

Frequent questions

How do I install Subscriptions?

With aiw add @ai-website/subscription-box. The command puts components/ui/subscription-box.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 Subscriptions have?

5 variants, Subscribe and save, Box plans, Box builder, Delivery schedule and How it works. You pick one with the variant prop, for example variant="plans".

Can I translate the texts of Subscriptions?

Yes. Subscriptions carries 40 texts of its own in English and German, such as “One-time purchase”, “Subscribe and save” and “Every”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Subscriptions?

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

Can I adjust Subscriptions without code?

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

Often searched

Jump to

Copied to the clipboard