Skip to content
UI Kit
Accent color

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

4 variants

Variants

Frequently bought together

The product and two add-ons with plus signs between them. Ticking off fades a tile and its plus, the total counts to its new value, the discount for all three fades in and the button says how many go into the cart.

Open in Studio

Build your set

Empty slots on top fill with every pick, each piece pops in and leaves with a tap. A bar with tier marks fills toward the next discount and says how many are missing, the total counts along.

Open in Studio

Ready-made sets

Curated sets as cards. The pieces stand side by side and fan out on hover, a sticker with the saving straightens up, chips list what is inside and the set price stands against the single prices.

Open in Studio

Packs and volume prices

One product in packs of 1, 3 and 6 as cards with the price per piece and the saving. The picture shows the picked pack as that many pieces that rise in one after another, the total counts to the new price.

Open in Studio

Details

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

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

Props

The BundlesProps interface from components/ui/bundles.tsx, with the comments from the code.

  • variantoptional
    BundlesVariant

    frequently-boughtbuild-a-setset-cardsvolume

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • itemsoptional
    BundleItem[]

    frequently-bought: the first is the product on the page

  • discountoptional
    number

    frequently-bought: discount in percent when all are taken

  • tiersoptional
    BundleTier[]

    build-a-set: tiers, smallest first

  • slotsoptional
    number

    build-a-set: number of slots

  • defaultPickedoptional
    string[]

    build-a-set: ids picked on load

  • setsoptional
    BundleSet[]
  • productoptional
    BundleItem & { unit?: string; /** One piece, e.g. "bag", shown as "€13.40 per bag" */ unitOne?: string }

    volume: the product and its packs

  • packsoptional
    BundlePack[]
  • currencyoptional
    string
  • onAddoptional
    (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
thisItemEnglish This itemGerman Dieser Artikel
totalEnglish TotalGerman Summe
saveEnglish You saveGerman Du sparst
saveShortEnglish SaveGerman Spare
addSelectedEnglish Add to cartGerman In den Warenkorb
addedEnglish Added to cartGerman Im Warenkorb
itemsEnglish itemsGerman Artikel
itemEnglish itemGerman Artikel
togetherEnglish togetherGerman zusammen
pickEnglish Add to setGerman Zum Set
pickedEnglish In your setGerman Im Set
removeEnglish RemoveGerman Entfernen
emptySlotEnglish Empty slotGerman Freier Platz
unlockEnglish more forGerman mehr für
offEnglish offGerman Rabatt
setFullEnglish Your set is completeGerman Dein Set ist komplett
addSetEnglish Add set to cartGerman Set in den Warenkorb
insideEnglish InsideGerman Darin
viewSetEnglish Add set to cartGerman Set in den Warenkorb
insteadEnglish instead ofGerman statt
perPieceEnglish per pieceGerman pro Stück
perEnglish perGerman pro
packEnglish Pack ofGerman Packung mit
oneEnglish SingleGerman Einzeln
choosePackEnglish Choose your packGerman Wähle deine Packung
bestValueEnglish Best valueGerman 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
components/ui/bundles.tsx
'use client';

import { useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } 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.

Often searched

Jump to

Copied to the clipboard