Bundles
Selling several things together. Frequently bought together with one total, a set to build with tiered discounts, ready-made sets and packs with a price per piece.
One of 2 components in Bundles and subscriptions
Variants
Details
What Bundles brings along, read from the registry and the source.
- Category
- Bundles and subscriptions
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/bundles.tsx
- npm packages
- None
- Languages
- English and German, 26 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The BundlesProps interface from components/ui/bundles.tsx, with the comments from the code.
variantoptionalBundlesVariantfrequently-boughtbuild-a-setset-cardsvolume
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringitemsoptionalBundleItem[]frequently-bought: the first is the product on the page
discountoptionalnumberfrequently-bought: discount in percent when all are taken
tiersoptionalBundleTier[]build-a-set: tiers, smallest first
slotsoptionalnumberbuild-a-set: number of slots
defaultPickedoptionalstring[]build-a-set: ids picked on load
setsoptionalBundleSet[]productoptionalBundleItem & { unit?: string; /** One piece, e.g. "bag", shown as "€13.40 per bag" */ unitOne?: string }volume: the product and its packs
packsoptionalBundlePack[]currencyoptionalstringonAddoptional(items: BundleItem[], total: number) => void
Built-in texts
Bundles writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 26 texts
| Key | English | German |
|---|---|---|
| thisItem | English This item | German Dieser Artikel |
| total | English Total | German Summe |
| save | English You save | German Du sparst |
| saveShort | English Save | German Spare |
| addSelected | English Add to cart | German In den Warenkorb |
| added | English Added to cart | German Im Warenkorb |
| items | English items | German Artikel |
| item | English item | German Artikel |
| together | English together | German zusammen |
| pick | English Add to set | German Zum Set |
| picked | English In your set | German Im Set |
| remove | English Remove | German Entfernen |
| emptySlot | English Empty slot | German Freier Platz |
| unlock | English more for | German mehr für |
| off | English off | German Rabatt |
| setFull | English Your set is complete | German Dein Set ist komplett |
| addSet | English Add set to cart | German Set in den Warenkorb |
| inside | English Inside | German Darin |
| viewSet | English Add set to cart | German Set in den Warenkorb |
| instead | English instead of | German statt |
| perPiece | English per piece | German pro Stück |
| per | English per | German pro |
| pack | English Pack of | German Packung mit |
| one | English Single | German Einzeln |
| choosePack | English Choose your pack | German Wähle deine Packung |
| bestValue | English Best value | German Bester Preis |
Accessibility
Read from the source of the component.
- Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- 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. 7 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, 625 lines
'use client';
import { useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { FlyUp, Price, ProductMedia, ShopMotion, Swap, TweenPrice, formatNumber, formatPercent, formatPrice } from '@/components/ui/product-image';
/*
* Selling several things together.
*
* frequently-bought the product and what people add, tick what you want, one total
* build-a-set pick pieces into empty slots, every tier unlocks a bigger discount
* set-cards ready-made sets with what is inside and what you save
* volume one product in packs of 1, 3 or 6, with the price per piece
*
* Prices are gross. Images come in as nodes.
*/
export type BundlesVariant = 'frequently-bought' | 'build-a-set' | 'set-cards' | 'volume';
export interface BundleItem {
id: string;
name: string;
/** e.g. "Clay · 350 ml" */
variant?: string;
price: number;
image?: React.ReactNode;
href?: string;
}
export interface BundleSet {
id: string;
name: string;
description?: string;
/** What is inside, shown as chips */
items: BundleItem[];
/** Set price; the sum of the items is the compare price */
price: number;
tag?: string;
href?: string;
}
export interface BundleTier {
/** Pieces needed */
count: number;
/** Discount in percent */
percent: number;
}
export interface BundlePack {
id: string;
/** Pieces in the pack */
count: number;
/** Price of the pack */
price: number;
tag?: string;
}
export interface BundlesProps {
variant?: BundlesVariant;
eyebrow?: string;
title?: string;
description?: string;
/** frequently-bought: the first is the product on the page */
items?: BundleItem[];
/** frequently-bought: discount in percent when all are taken */
discount?: number;
/** build-a-set: tiers, smallest first */
tiers?: BundleTier[];
/** build-a-set: number of slots */
slots?: number;
/** build-a-set: ids picked on load */
defaultPicked?: string[];
sets?: BundleSet[];
/** volume: the product and its packs */
product?: BundleItem & { unit?: string; /** One piece, e.g. "bag", shown as "€13.40 per bag" */ unitOne?: string };
packs?: BundlePack[];
currency?: string;
onAdd?: (items: BundleItem[], total: number) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
thisItem: 'This item',
total: 'Total',
save: 'You save',
saveShort: 'Save',
addSelected: 'Add to cart',
added: 'Added to cart',
items: 'items',
item: 'item',
together: 'together',
pick: 'Add to set',
picked: 'In your set',
remove: 'Remove',
emptySlot: 'Empty slot',
unlock: 'more for',
off: 'off',
setFull: 'Your set is complete',
addSet: 'Add set to cart',
inside: 'Inside',
viewSet: 'Add set to cart',
instead: 'instead of',
perPiece: 'per piece',
per: 'per',
pack: 'Pack of',
one: 'Single',
choosePack: 'Choose your pack',
bestValue: 'Best value',
},
de: {
thisItem: 'Dieser Artikel',
total: 'Summe',
save: 'Du sparst',
saveShort: 'Spare',
addSelected: 'In den Warenkorb',
added: 'Im Warenkorb',
items: 'Artikel',
item: 'Artikel',
together: 'zusammen',
pick: 'Zum Set',
picked: 'Im Set',
remove: 'Entfernen',
emptySlot: 'Freier Platz',
unlock: 'mehr für',
off: 'Rabatt',
setFull: 'Dein Set ist komplett',
addSet: 'Set in den Warenkorb',
inside: 'Darin',
viewSet: 'Set in den Warenkorb',
instead: 'statt',
perPiece: 'pro Stück',
per: 'pro',
pack: 'Packung mit',
one: 'Einzeln',
choosePack: 'Wähle deine Packung',
bestValue: 'Bester Preis',
},
};
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 SAVE = 'text-success-foreground';
function Head({ eyebrow, title, description }: { eyebrow?: string; title?: string; description?: string }) {
if (!title) return null;
return (
<div className="mb-10 max-w-[40rem] sm:mb-12">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
);
}
/** A tick box drawn in the kit style, the real input stays for keyboards */
function Tick({ on }: { on: boolean }) {
return (
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-[5px] border transition-colors duration-200', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{on && <Icon name="check" size={13} strokeWidth={2.4} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" />}
</span>
);
}
/** The add button with "+1" and a swap to "Added" */
function AddButton({ label, done, count, onClick, disabled, className, size = 'lg' }: { label: string; done: string; count: number; onClick: () => void; disabled?: boolean; className?: string; size?: 'md' | 'lg' }) {
const [state, setState] = useState(0);
return (
<span className={cn('relative block', className)}>
<FlyUp count={state} label={`+${count}`} />
<Button
size={size}
className="w-full"
disabled={disabled}
iconLeft={state ? 'check' : 'bag'}
onClick={() => {
setState((s) => s + 1);
onClick();
}}
>
<Swap value={state > 0}>{state ? done : label}</Swap>
</Button>
</span>
);
}
/* ------------------------------------------------------------------ */
/* frequently-bought */
/* ------------------------------------------------------------------ */
function FrequentlyBought(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const items = p.items ?? [];
const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(items.map((i) => [i.id, true])));
const picked = items.filter((i) => on[i.id]);
const all = picked.length === items.length && items.length > 1;
const sum = picked.reduce((s, i) => s + i.price, 0);
const discount = all ? round((sum * (p.discount ?? 0)) / 100) : 0;
const total = sum - discount;
const name = useId();
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)_22rem] lg:items-center lg:gap-12">
<Stagger step={90} as="ul" className="flex items-center gap-8 sm:gap-14">
{items.map((it, i) => (
<li key={it.id} className="relative min-w-0 flex-1">
{i > 0 && (
<span aria-hidden className={cn('absolute -left-4 top-1/2 z-10 grid size-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border bg-background text-muted-foreground transition-opacity duration-300 sm:-left-7 sm:size-9', !on[it.id] && 'opacity-40')}>
<Icon name="plus" size={15} />
</span>
)}
<label className={cn('group/tile relative block min-w-0 flex-1 cursor-pointer rounded-xl has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]')}>
<input type="checkbox" name={name} checked={!!on[it.id]} disabled={i === 0} onChange={(e) => setOn((o) => ({ ...o, [it.id]: e.target.checked }))} className="sr-only" />
<ProductMedia className={cn('aspect-square transition-[opacity,filter,transform] duration-500 ease-[var(--ease-kit)]', !on[it.id] && 'scale-[0.96] opacity-45 grayscale')}>
<span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/tile:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{it.image}</span>
</ProductMedia>
<span className="absolute left-2 top-2">{i === 0 ? <span className="rounded-full bg-background/90 px-2 py-0.5 text-[11.5px] font-medium text-foreground backdrop-blur max-sm:hidden">{t.thisItem}</span> : <Tick on={!!on[it.id]} />}</span>
<span className="sr-only">{it.name}</span>
</label>
</li>
))}
</Stagger>
<Reveal effect="rise" className={cn(CARD, 'p-5 sm:p-6')}>
<ul className="space-y-1">
{items.map((it, i) => (
<li key={it.id}>
<label className={cn('flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 transition-colors', i === 0 ? 'cursor-default' : 'cursor-pointer hover:bg-surface')}>
<input type="checkbox" checked={!!on[it.id]} disabled={i === 0} onChange={(e) => setOn((o) => ({ ...o, [it.id]: e.target.checked }))} className="peer sr-only" />
<span className="peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] rounded-[5px]">
<Tick on={!!on[it.id]} />
</span>
<span className={cn('min-w-0 flex-1 transition-opacity', !on[it.id] && 'opacity-55')}>
<span className="block truncate text-[14px] font-medium text-foreground">
{i === 0 && <span className="text-muted-foreground">{t.thisItem} · </span>}
{it.name}
</span>
{it.variant && <span className="block truncate text-[12.5px] text-muted-foreground">{it.variant}</span>}
</span>
<span className={cn('text-[14px] tabular-nums text-foreground transition-opacity', !on[it.id] && 'opacity-55')}>{formatPrice(locale, it.price, currency)}</span>
</label>
</li>
))}
</ul>
<div className="mt-4 border-t border-border pt-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-[14px] text-muted-foreground">
{t.total} · {formatNumber(locale, picked.length)} {picked.length === 1 ? t.item : t.items}
</span>
<span className="text-right">
<TweenPrice value={total} currency={currency} className="block text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
{discount > 0 && <s className="text-[13px] tabular-nums text-muted-foreground">{formatPrice(locale, sum, currency)}</s>}
</span>
</div>
<p className={cn('mt-1 flex h-5 items-center gap-1.5 text-[13px] font-medium transition-opacity duration-300', SAVE, discount > 0 ? 'opacity-100' : 'opacity-0')} aria-live="polite">
<Icon name="badge-percent" size={14} />
{t.save} {formatPrice(locale, discount, currency)} {t.together}
</p>
<AddButton className="mt-4" label={`${t.addSelected} · ${picked.length}`} done={t.added} count={picked.length} onClick={() => p.onAdd?.(picked, total)} />
</div>
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* build-a-set */
/* ------------------------------------------------------------------ */
function BuildASet(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const items = p.items ?? [];
const tiers = p.tiers ?? [];
const slots = p.slots ?? tiers[tiers.length - 1]?.count ?? 3;
const [picked, setPicked] = useState<string[]>(p.defaultPicked ?? []);
const list = picked.map((id) => items.find((i) => i.id === id)!).filter(Boolean);
const sum = list.reduce((s, i) => s + i.price, 0);
const tier = [...tiers].reverse().find((x) => list.length >= x.count);
const next = tiers.find((x) => list.length < x.count);
const discount = tier ? round((sum * tier.percent) / 100) : 0;
const total = sum - discount;
const full = list.length >= slots;
const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : l.length >= slots ? l : [...l, id]));
const [ref, inView] = useInView<HTMLDivElement>();
const summary = (
<>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[14px] text-muted-foreground">{t.total}</span>
<span className="text-right">
<TweenPrice value={total} currency={currency} className="block text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
{discount > 0 && <s className="text-[13px] tabular-nums text-muted-foreground">{formatPrice(locale, sum, currency)}</s>}
</span>
</div>
<p className={cn('mt-1 text-[13px] font-medium', discount > 0 ? SAVE : 'text-muted-foreground')} aria-live="polite">
{full ? (
<span className="inline-flex items-center gap-1.5">
<Icon name="check-circle" size={14} />
{t.setFull} · {t.save} {formatPrice(locale, discount, currency)}
</span>
) : next ? (
<>
{formatNumber(locale, next.count - list.length)} {t.unlock} {formatPercent(locale, next.percent)} {t.off}
</>
) : null}
</p>
</>
);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div ref={ref} className={cn(CARD, 'p-5 sm:p-6')}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-center lg:gap-10">
<div>
<ol className="grid max-w-[40rem] gap-2 sm:gap-3" style={{ gridTemplateColumns: `repeat(${slots}, minmax(0, 1fr))` }} aria-label={t.picked}>
{Array.from({ length: slots }, (_, i) => {
const it = list[i];
return (
<li key={i} className="relative aspect-square">
{it ? (
<button type="button" onClick={() => toggle(it.id)} aria-label={`${t.remove}, ${it.name}`} className={cn('group/slot relative block size-full animate-[kitshop-pop_0.45s_var(--ease-kit)] overflow-hidden rounded-xl bg-surface-2', FOCUS)}>
<span className="block size-full [&>*]:size-full">{it.image}</span>
<span className="absolute right-1.5 top-1.5 grid size-7 place-items-center rounded-full bg-background/90 text-foreground opacity-100 shadow-[var(--shadow-sm)] backdrop-blur transition-opacity sm:opacity-0 sm:group-hover/slot:opacity-100 sm:group-focus-visible/slot:opacity-100">
<Icon name="x" size={13} />
</span>
</button>
) : (
<span className="grid size-full place-items-center rounded-xl border border-dashed border-border-strong text-[12px] text-subtle-foreground">
<span className="grid justify-items-center gap-1">
<Icon name="plus" size={16} />
<span className="tabular-nums">{i + 1}</span>
</span>
<span className="sr-only">{t.emptySlot}</span>
</span>
)}
</li>
);
})}
</ol>
{tiers.length > 0 && (
<div className="mt-5 max-w-[40rem]">
<div className="relative h-1.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<span aria-hidden className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(list.length / slots, 3) : 0})` }} />
{tiers.map((x) => (
<span key={x.count} aria-hidden className={cn('absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card transition-colors duration-300', list.length >= x.count ? 'bg-primary' : 'bg-border-strong')} style={{ left: `${round((x.count / slots) * 100, 2)}%` }} />
))}
</div>
<div className="relative mt-3 h-9 text-[12.5px]">
{tiers.map((x) => (
<span key={x.count} className={cn('absolute -translate-x-1/2 whitespace-nowrap text-center leading-tight transition-colors last:-translate-x-full last:text-right', list.length >= x.count ? SAVE : 'text-muted-foreground')} style={{ left: `${round((x.count / slots) * 100, 2)}%` }}>
<span className="block font-semibold">-{formatPercent(locale, x.percent)}</span>
<span className="block text-[11.5px] opacity-80">
{formatNumber(locale, x.count)} {t.items}
</span>
</span>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border pt-5 lg:border-l lg:border-t-0 lg:ps-10 lg:pt-0">
{summary}
<AddButton className="mt-4" label={t.addSet} done={t.added} count={list.length} disabled={!list.length} onClick={() => p.onAdd?.(list, total)} />
</div>
</div>
</div>
<Stagger step={60} as="ul" className="mt-8 grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 sm:gap-x-5 lg:grid-cols-6">
{items.map((it) => {
const on = picked.includes(it.id);
return (
<li key={it.id} className="flex flex-col">
<button
type="button"
aria-pressed={on}
disabled={!on && full}
onClick={() => toggle(it.id)}
className={cn('group/card relative block rounded-xl text-left disabled:cursor-not-allowed', FOCUS)}
>
<ProductMedia className={cn('aspect-square transition-[box-shadow,opacity] duration-300', on && 'shadow-[0_0_0_2px_var(--primary)]', !on && full && 'opacity-50')}>
<span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{it.image}</span>
</ProductMedia>
<span className={cn('absolute right-2 top-2 grid size-8 place-items-center rounded-full shadow-[var(--shadow-sm)] transition-colors duration-300', on ? 'bg-primary text-primary-foreground' : 'bg-background/90 text-foreground backdrop-blur')}>
<Icon name={on ? 'check' : 'plus'} size={15} className={cn(on && 'animate-[kitshop-pop_0.35s_var(--ease-kit)]')} />
</span>
<span className="mt-2.5 block text-[14px] font-medium leading-snug text-foreground">{it.name}</span>
{it.variant && <span className="block text-[12.5px] text-muted-foreground">{it.variant}</span>}
<span className="mt-1 block text-[14px] tabular-nums text-foreground">{formatPrice(locale, it.price, currency)}</span>
</button>
</li>
);
})}
</Stagger>
{/* Phones and tablets: a bar that stays at the bottom while picking */}
<div className="sticky bottom-3 z-10 mt-6 lg:hidden">
<div className="flex items-center gap-3 rounded-2xl border border-border bg-background/92 p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md">
<div className="min-w-0 flex-1">
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{formatNumber(locale, list.length)} / {formatNumber(locale, slots)}
</p>
<TweenPrice value={total} currency={currency} className="text-[17px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<Button size="md" iconLeft="bag" disabled={!list.length} onClick={() => p.onAdd?.(list, total)}>
{t.addSet}
</Button>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* set-cards */
/* ------------------------------------------------------------------ */
function SetCards(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const sets = p.sets ?? [];
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<Stagger step={90} as="ul" className="grid grid-cols-1 gap-5 lg:grid-cols-3 lg:gap-6">
{sets.map((set) => {
const sum = set.items.reduce((s, i) => s + i.price, 0);
const save = Math.max(0, sum - set.price);
const pct = sum ? Math.round((save / sum) * 100) : 0;
const shown = set.items.slice(0, 3);
return (
<li key={set.id}>
<article className={cn(CARD, 'group/set flex h-full flex-col p-2.5 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:gap-2 lg:flex lg:gap-0 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0')}>
{/* The pieces stand side by side and fan out on hover */}
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-surface-2 sm:aspect-auto sm:min-h-[17rem] lg:aspect-[4/3] lg:min-h-0">
<div aria-hidden className="absolute inset-0 bg-[radial-gradient(70%_60%_at_50%_30%,rgb(255_255_255/0.7),transparent_70%)] dark:bg-[radial-gradient(70%_60%_at_50%_30%,rgb(255_255_255/0.07),transparent_70%)]" />
<div className="absolute inset-0 flex items-center justify-center">
{shown.map((it, i) => {
const mid = (shown.length - 1) / 2;
const off = i - mid;
return (
<span
key={it.id}
className="relative -mx-[1.5%] block w-[34%] transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none [&>*]:aspect-square [&>*]:w-full"
style={{ zIndex: 10 - Math.abs(Math.round(off * 2)), transform: `translateY(${round(Math.abs(off) * 6)}%) rotate(${round(off * 4)}deg)` }}
>
<span className="block transition-transform duration-700 ease-[var(--ease-kit)] group-hover/set:[transform:var(--fan)] motion-reduce:!transform-none [&>*]:aspect-square [&>*]:w-full" style={{ '--fan': `translateX(${round(off * 14)}%) rotate(${round(off * 4)}deg)` } as React.CSSProperties}>
{it.image}
</span>
</span>
);
})}
</div>
{pct > 0 && (
<span className="absolute right-3 top-3 grid size-14 rotate-[-8deg] place-items-center rounded-full bg-primary text-center text-[13px] font-semibold leading-tight text-primary-foreground shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] group-hover/set:rotate-0 group-hover/set:scale-105">
-{formatPercent(locale, pct)}
</span>
)}
{set.tag && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12px] font-medium text-foreground backdrop-blur">{set.tag}</span>}
</div>
<div className="flex flex-1 flex-col px-2.5 pb-2.5 pt-4">
<h3 className="text-[18px] font-semibold tracking-[-0.015em] text-foreground">{set.name}</h3>
{set.description && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{set.description}</p>}
<p className="mt-4 text-[12px] font-medium text-subtle-foreground">{t.inside}</p>
<ul className="mt-1.5 flex flex-wrap gap-1.5">
{set.items.map((it) => (
<li key={it.id} className="rounded-md bg-muted px-2 py-1 text-[12.5px] text-foreground">
{it.name}
</li>
))}
</ul>
<div className="mt-auto pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<Price value={set.price} compareAt={sum} currency={currency} size="lg" />
{save > 0 && (
<span className={cn('text-[13px] font-medium', SAVE)}>
{t.saveShort} {formatPrice(locale, save, currency)}
</span>
)}
</div>
<AddButton size="md" className="mt-4" label={t.viewSet} done={t.added} count={set.items.length} onClick={() => p.onAdd?.(set.items, set.price)} />
</div>
</div>
</article>
</li>
);
})}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* volume */
/* ------------------------------------------------------------------ */
function Volume(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const packs = p.packs ?? [];
const product = p.product;
const single = product?.price ?? packs[0]?.price ?? 0;
const best = packs.reduce((b, x) => (x.price / x.count < b.price / b.count ? x : b), packs[0] ?? { id: '', count: 1, price: single });
const [packId, setPackId] = useState(packs.find((x) => x.tag)?.id ?? packs[0]?.id ?? '');
const pack = packs.find((x) => x.id === packId) ?? packs[0];
const name = useId();
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-center lg:gap-16">
<Reveal effect="scale" className="relative">
<ProductMedia className="aspect-[4/3] lg:aspect-square">
{/* The picked pack shows as that many pieces, spread like on a counter */}
<span aria-hidden className="absolute inset-0 bg-[radial-gradient(60%_55%_at_50%_35%,rgb(255_255_255/0.75),transparent_70%)] dark:bg-[radial-gradient(60%_55%_at_50%_35%,rgb(255_255_255/0.08),transparent_70%)]" />
<span className="absolute inset-0 flex items-center justify-center">
{Array.from({ length: Math.min(pack?.count ?? 1, 6) }, (_, i) => {
const n = Math.min(pack?.count ?? 1, 6);
const off = i - (n - 1) / 2;
return (
<span
key={`${pack?.id}-${i}`}
className="relative block w-[50%] animate-[kitshop-zoom_0.5s_var(--ease-kit)_both] [&>*]:aspect-square [&>*]:w-full"
style={{ marginInline: n > 1 ? `-${round(8 + n * 2.6, 1)}%` : 0, transform: `translateY(${round(Math.abs(off) * -3, 1)}%) scale(${round(1 - Math.abs(off) * 0.06, 3)})`, zIndex: 10 - Math.round(Math.abs(off) * 2), animationDelay: `${i * 60}ms` }}
>
{product?.image}
</span>
);
})}
</span>
</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>
{p.description && (
<p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{p.description}
</p>
)}
<fieldset className="mt-8">
<legend className="mb-3 text-[14px] font-medium text-foreground">{t.choosePack}</legend>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-2">
{packs.map((x) => {
const on = x.id === packId;
const unit = x.price / x.count;
const pct = single ? Math.round((1 - unit / single) * 100) : 0;
return (
<label
key={x.id}
className={cn(
'relative flex cursor-pointer items-center justify-between gap-3 rounded-xl border p-4 transition-[border-color,box-shadow] sm:flex-col sm:items-start sm:justify-start has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong',
)}
>
<input type="radio" name={name} checked={on} onChange={() => setPackId(x.id)} className="sr-only" />
{(x.tag || x.id === best.id) && x.count > 1 && (
<span className="absolute -top-2.5 left-4 rounded-full bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground shadow-[var(--shadow-sm)]">{x.tag ?? t.bestValue}</span>
)}
<span>
<span className="block text-[15px] font-semibold text-foreground">{x.count === 1 ? t.one : `${formatNumber(locale, x.count)} ${product?.unit ?? t.items}`}</span>
<span className="block text-[12.5px] tabular-nums text-muted-foreground">
{formatPrice(locale, unit, currency)} {product?.unitOne ? `${t.per} ${product.unitOne}` : t.perPiece}
</span>
</span>
<span className="text-right sm:mt-3 sm:text-left">
<span className="block text-[17px] font-semibold tabular-nums text-foreground">{formatPrice(locale, x.price, currency)}</span>
<span className={cn('block text-[12.5px] font-medium', pct > 0 ? SAVE : 'text-transparent')}>{pct > 0 ? `-${formatPercent(locale, pct)}` : '·'}</span>
</span>
</label>
);
})}
</div>
</fieldset>
{pack && (
<div className="mt-6 flex items-center justify-between gap-4">
<div>
<TweenPrice value={pack.price} currency={currency} className="text-[26px] font-semibold tracking-[-0.02em] text-foreground" />
{pack.count > 1 && (
<p className="text-[13px] text-muted-foreground">
{t.instead} <s className="tabular-nums">{formatPrice(locale, single * pack.count, currency)}</s>
</p>
)}
</div>
<AddButton className="w-44 sm:w-56" label={t.addSelected} done={t.added} count={pack.count} onClick={() => product && p.onAdd?.([product], pack.price)} />
</div>
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Bundles in four layouts. frequently-bought: the product and its add-ons.
* build-a-set: pick pieces for a tiered discount. set-cards: ready sets.
* volume: packs with a price per piece.
*/
export function Bundles(props: BundlesProps) {
const { variant = 'frequently-bought' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'build-a-set') return <BuildASet {...props} t={t} />;
if (variant === 'set-cards') return <SetCards {...props} t={t} />;
if (variant === 'volume') return <Volume {...props} t={t} />;
return <FrequentlyBought {...props} t={t} />;
}Frequent questions
How do I install Bundles?
With aiw add @ai-website/bundles. The command puts components/ui/bundles.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 Bundles have?
4 variants, Frequently bought together, Build your set, Ready-made sets and Packs and volume prices. You pick one with the variant prop, for example variant="build-a-set".
Can I translate the texts of Bundles?
Yes. Bundles carries 26 texts of its own in English and German, such as “This item”, “Total” and “You save”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Bundles?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Bundles with aiw_components and install it with aiw_add @ai-website/bundles. Name the variant in your prompt, for example variant="frequently-bought", and the assistant writes your content straight into the props.
Can I adjust Bundles without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching Bundles code to copy.