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
Variants
Details
What Subscriptions brings along, read from the registry and the source.
- Category
- Bundles and subscriptions
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/subscription-box.tsx
- npm packages
- None
- Languages
- English and German, 40 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SubscriptionBoxProps interface from components/ui/subscription-box.tsx, with the comments from the code.
variantoptionalSubscriptionBoxVariantpurchase-toggleplansbuilderschedulehow-it-works
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringproductoptional{ name: string; subtitle?: string; price: number; image?: React.ReactNode }purchase-toggle: the product
discountoptionalnumberDiscount in percent for subscribers
rhythmsoptionalnumber[]Delivery rhythms in weeks
defaultRhythmoptionalnumberplansoptionalSubPlan[]unitOneoptionalstringplans: one piece, e.g. "bag", shown as "€12.45 per bag"
yearlyoptionalnumberplans: yearly discount in percent on top
questionsoptional[string, string, string]builder: the question of each step, e.g. ["Which coffee?", "How much?", "How often?"]
choicesoptionalSubOption[]builder: the choices of step one, two and three
sizesoptional(SubOption & { price: number })[]deliveriesoptionalSubDelivery[]stepsoptional{ icon?: IconName; title: string; text: string }[]perksoptionalstring[]currencyoptionalstringonSubscribeoptional(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
| Key | English | German |
|---|---|---|
| oneTime | English One-time purchase | German Einmal kaufen |
| subscribe | English Subscribe and save | German Abonnieren und sparen |
| every | English Every | German Alle |
| everyWeek | English Every week | German Jede Woche |
| week | English week | German Woche |
| weeks | English weeks | German Wochen |
| deliverEvery | English Deliver every | German Liefern alle |
| perDelivery | English per delivery | German pro Lieferung |
| perPiece | English per piece | German pro Stück |
| per | English per | German pro |
| save | English Save | German Spare |
| addToCart | English Add to cart | German In den Warenkorb |
| startSub | English Start subscription | German Abo starten |
| started | English Subscription started | German Abo gestartet |
| cancelAny | English Skip, pause or cancel any time in your account. | German Jederzeit im Konto aussetzen, pausieren oder kündigen. |
| monthly | English Monthly | German Monatlich |
| yearly | English Yearly | German Jährlich |
| billed | English billed yearly | German jährlich abgerechnet |
| choose | English Choose | German Auswählen |
| chosen | English Chosen | German Ausgewählt |
| step | English Step | German Schritt |
| of | English of | German von |
| next | English Next | German Weiter |
| back | English Back | German Zurück |
| summary | English Your box | German Deine Box |
| what | English What | German Was |
| howMuch | English How much | German Wie viel |
| howOften | English How often | German Wie oft |
| then | English then | German danach |
| shipped | English Shipped | German Versendet |
| upNext | English Up next | German Als Nächstes |
| planned | English Planned | German Geplant |
| skipped | English Skipped | German Ausgesetzt |
| skip | English Skip | German Aussetzen |
| undo | English Undo skip | German Doch liefern |
| move | English Move a week | German Eine Woche später |
| moved | English Moved | German Verschoben |
| pause | English Pause subscription | German Abo pausieren |
| resume | English Resume subscription | German Abo fortsetzen |
| paused | English 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
'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.