Skip to content
UI Kit
Accent color

Creator shop

A shop for digital products. A storefront by kind, presets with a live before and after slider, a product page with its files and licences, a bundle builder and pay what you want.

One of 3 components in Creators

5 variants

Variants

Storefront

Products by kind behind a gliding filter, file formats and rating on every card. Light follows the pointer, the picture zooms, the bag counts along.

Open in Studio

Presets with before and after

One photo with a slider between before and after. Every look below applies live, the handle hints once that it moves. Keyboard works on the slider.

Open in Studio

Digital product

One product with what is inside as a file tree that unfolds while scrolling. The licence decides the price, the price glides, download right after paying.

Open in Studio

Bundle builder

Pick products, the discount grows with every one. A bar with steps shows how far it is to the next discount, the total glides down.

Open in Studio

Pay what you want

A free product with an optional price. A slider and quick amounts, a short line that answers the choice, the download by email. The picture leans in as the price grows.

Open in Studio

Details

What Creator shop brings along, read from the registry and the source.

Category
Creators
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/creator-shop.tsx
Brings along
utilskitbuttoniconmotionsection
npm packages
None
Languages
English and German, 39 texts of its own
In collections
Creator page

Props

The CreatorShopProps interface from components/ui/creator-shop.tsx, with the comments from the code.

  • variantoptional
    CreatorShopVariant

    storefrontpresetsproductbundlepay-what-you-want

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • productsoptional
    ShopProduct[]
  • productoptional
    ShopProduct & { description?: string; points?: string[] }

    presets, product, pay-what-you-want: the product shown

  • presetsoptional
    ShopPreset[]
  • photooptional
    React.ReactNode

    presets: the photo the looks are applied to

  • filesoptional
    ShopFile[]
  • licencesoptional
    ShopLicence[]
  • tiersoptional
    Record<number, number>

    bundle: discount in percent by number of products, e.g. { 2: 10, 3: 20, 4: 30 }

  • suggestedoptional
    number

    pay-what-you-want: suggested amounts and the top of the slider

  • maxoptional
    number
  • currencyoptional
    string
  • onBuyoptional
    (data: Record<string, unknown>) => void

Built-in texts

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

Show all 39 texts
allEnglish AllGerman Alle
filterEnglish Filter by kindGerman Nach Art filtern
instantEnglish Instant downloadGerman Sofort-Download
addEnglish Add to bagGerman In den Korb
addedEnglish In your bagGerman Im Warenkorb
beforeEnglish BeforeGerman Vorher
afterEnglish AfterGerman Nachher
compareEnglish Drag to compare before and afterGerman Ziehen, um vorher und nachher zu vergleichen
looksEnglish LooksGerman Looks
buyEnglish Buy for {price}German Für {price} kaufen
buyShortEnglish Buy forGerman Kaufen für
insideEnglish What is insideGerman Was drin ist
filesEnglish {n} filesGerman {n} Dateien
licenceEnglish LicenceGerman Lizenz
secureEnglish Paid securely, files arrive in secondsGerman Sicher bezahlt, Dateien kommen in Sekunden
lifetimeEnglish Updates for lifeGerman Updates für immer
chooseEnglish Choose productsGerman Produkte wählen
selectedEnglish {n} selectedGerman {n} gewählt
bundleTotalEnglish Bundle priceGerman Preis fürs Paket
saveEnglish You save {v}German Du sparst {v}
nextTierEnglish Add {n} more for {p}% offGerman Noch {n} dazu für {p} % Rabatt
topTierEnglish Biggest discount reachedGerman Höchster Rabatt erreicht
getBundleEnglish Get the bundleGerman Paket holen
offEnglish {p}% offGerman {p} % Rabatt
pwywEnglish Pay what you wantGerman Zahl, was du willst
freeEnglish FreeGerman Kostenlos
nameEnglish PriceGerman Preis
sliderLabelEnglish Your priceGerman Dein Preis
thanksEnglish Thank you, that helps me make the next one.German Danke, das hilft mir beim nächsten.
zeroEnglish Free is fine. Enjoy it.German Kostenlos ist völlig in Ordnung. Viel Freude damit.
emailEnglish Email for the downloadGerman E-Mail für den Download
emailPhEnglish you@example.comGerman du@beispiel.de
downloadEnglish Send me the downloadGerman Download schicken
downloadForEnglish Pay {price} and downloadGerman {price} zahlen und herunterladen
invalidEnglish Please enter an email address like name@example.com.German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
sentEnglish The download link is on its way to {e}.German Der Download-Link ist unterwegs an {e}.
suggestedEnglish SuggestedGerman Vorschlag
salesEnglish {n} soldGerman {n} verkauft
ratingEnglish {r} of 5 starsGerman {r} von 5 Sternen

Accessibility

Read from the source of the component.

  • Creator shop handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
  • Sets aria-checked, aria-describedby, aria-invalid, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles radio, radiogroup, status and tab.
  • 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, 805 lines
components/ui/creator-shop.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Stagger, useScrollFade, Magnetic } from '@/components/ui/motion';
import { Section, SectionHeader } from '@/components/ui/section';

/*
 * A shop for digital products a creator makes: presets, templates, ebooks,
 * brushes, small courses. Everything is delivered as a download, so the
 * sections talk about files, formats and licences instead of shipping.
 *
 *   storefront  products by kind with a filter, file formats on each card, a preview that zooms
 *   presets     one photo, a slider between before and after, every preset applied live
 *   product     one product: what is inside as a file list, licence choice with the price, instant download
 *   bundle      pick products, the discount grows with every one, a bar shows the next step
 *   pay-what-you-want  a free product with a slider for an optional price and the email for the download
 */

export type CreatorShopVariant = 'storefront' | 'presets' | 'product' | 'bundle' | 'pay-what-you-want';

export interface ShopProduct {
  id: string;
  title: string;
  /** e.g. "Presets" or "Template" */
  kind: string;
  price: number;
  was?: number;
  /** File formats, e.g. ["XMP", "DNG"] */
  formats?: string[];
  /** e.g. "24 presets" */
  count?: string;
  badge?: string;
  rating?: number;
  sales?: number;
  image?: React.ReactNode;
  href?: string;
}

export interface ShopPreset {
  id: string;
  name: string;
  /** A CSS filter that shows the look on the photo, e.g. "saturate(1.2) sepia(.2)" */
  filter: string;
}

export interface ShopFile {
  name: string;
  size?: string;
  /** Files inside a folder */
  children?: ShopFile[];
}

export interface ShopLicence {
  id: string;
  label: string;
  /** What it allows, one line */
  detail: string;
  price: number;
}

export interface CreatorShopProps {
  variant?: CreatorShopVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  products?: ShopProduct[];
  /** presets, product, pay-what-you-want: the product shown */
  product?: ShopProduct & { description?: string; points?: string[] };
  presets?: ShopPreset[];
  /** presets: the photo the looks are applied to */
  photo?: React.ReactNode;
  files?: ShopFile[];
  licences?: ShopLicence[];
  /** bundle: discount in percent by number of products, e.g. { 2: 10, 3: 20, 4: 30 } */
  tiers?: Record<number, number>;
  /** pay-what-you-want: suggested amounts and the top of the slider */
  suggested?: number;
  max?: number;
  currency?: string;
  onBuy?: (data: Record<string, unknown>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    all: 'All',
    filter: 'Filter by kind',
    instant: 'Instant download',
    add: 'Add to bag',
    added: 'In your bag',
    before: 'Before',
    after: 'After',
    compare: 'Drag to compare before and after',
    looks: 'Looks',
    buy: 'Buy for {price}',
    buyShort: 'Buy for',
    inside: 'What is inside',
    files: '{n} files',
    licence: 'Licence',
    secure: 'Paid securely, files arrive in seconds',
    lifetime: 'Updates for life',
    choose: 'Choose products',
    selected: '{n} selected',
    bundleTotal: 'Bundle price',
    save: 'You save {v}',
    nextTier: 'Add {n} more for {p}% off',
    topTier: 'Biggest discount reached',
    getBundle: 'Get the bundle',
    off: '{p}% off',
    pwyw: 'Pay what you want',
    free: 'Free',
    name: 'Price',
    sliderLabel: 'Your price',
    thanks: 'Thank you, that helps me make the next one.',
    zero: 'Free is fine. Enjoy it.',
    email: 'Email for the download',
    emailPh: 'you@example.com',
    download: 'Send me the download',
    downloadFor: 'Pay {price} and download',
    invalid: 'Please enter an email address like name@example.com.',
    sent: 'The download link is on its way to {e}.',
    suggested: 'Suggested',
    sales: '{n} sold',
    rating: '{r} of 5 stars',
  },
  de: {
    all: 'Alle',
    filter: 'Nach Art filtern',
    instant: 'Sofort-Download',
    add: 'In den Korb',
    added: 'Im Warenkorb',
    before: 'Vorher',
    after: 'Nachher',
    compare: 'Ziehen, um vorher und nachher zu vergleichen',
    looks: 'Looks',
    buy: 'Für {price} kaufen',
    buyShort: 'Kaufen für',
    inside: 'Was drin ist',
    files: '{n} Dateien',
    licence: 'Lizenz',
    secure: 'Sicher bezahlt, Dateien kommen in Sekunden',
    lifetime: 'Updates für immer',
    choose: 'Produkte wählen',
    selected: '{n} gewählt',
    bundleTotal: 'Preis fürs Paket',
    save: 'Du sparst {v}',
    nextTier: 'Noch {n} dazu für {p} % Rabatt',
    topTier: 'Höchster Rabatt erreicht',
    getBundle: 'Paket holen',
    off: '{p} % Rabatt',
    pwyw: 'Zahl, was du willst',
    free: 'Kostenlos',
    name: 'Preis',
    sliderLabel: 'Dein Preis',
    thanks: 'Danke, das hilft mir beim nächsten.',
    zero: 'Kostenlos ist völlig in Ordnung. Viel Freude damit.',
    email: 'E-Mail für den Download',
    emailPh: 'du@beispiel.de',
    download: 'Download schicken',
    downloadFor: '{price} zahlen und herunterladen',
    invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
    sent: 'Der Download-Link ist unterwegs an {e}.',
    suggested: 'Vorschlag',
    sales: '{n} verkauft',
    rating: '{r} von 5 Sternen',
  },
};
type Texts = typeof TEXTS.en;

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
function money(value: number, currency: string, locale: Locale) {
  const whole = Number.isInteger(value);
  return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 }).format(value);
}


/** Arrow keys, Home and End move the choice inside a radio group or a tab list (APG) */
function arrowKeys(e: React.KeyboardEvent<HTMLElement>) {
  const keys = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
  if (!keys.includes(e.key)) return;
  const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled), [role="tab"]:not(:disabled)')];
  const i = items.indexOf(document.activeElement as HTMLButtonElement);
  if (i < 0) return;
  e.preventDefault();
  const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
  const n = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : (i + step + items.length) % items.length;
  items[n].focus();
  items[n].click();
}

const MOTION_CSS = `
@keyframes kitshop-in{from{opacity:0;transform:translateY(10px) scale(.98)}}
@keyframes kitshop-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitshop-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitshop-hint{0%,100%{transform:translateX(0)}50%{transform:translateX(-14px)}}
@media (prefers-reduced-motion:reduce){[class*="kitshop-"],.kitshop-anim{animation:none!important}}`;

function ShopMotion() {
  return (
    <style href="kitshop-motion" precedence="default">
      {MOTION_CSS}
    </style>
  );
}

/** A number that glides to its new value. Starts at the value, so server and browser agree. */
function useTween(value: number, ms = 450) {
  const [shown, setShown] = useState(value);
  const from = useRef(value);
  useEffect(() => {
    const start = from.current;
    if (start === value) return;
    if (prefersReducedMotion()) {
      from.current = value;
      setShown(value);
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / ms);
      const v = k >= 1 ? value : start + (value - start) * (1 - Math.pow(1 - k, 3));
      from.current = v;
      setShown(v);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, ms]);
  return shown;
}
function Money({ value, currency }: { value: number; currency: string }) {
  const locale = useLocale();
  const v = useTween(value);
  return <>{money(Math.round(v * 100) / 100, currency, locale)}</>;
}

function Formats({ formats = [] }: { formats?: string[] }) {
  if (!formats.length) return null;
  return (
    <span className="flex flex-wrap gap-1">
      {formats.map((f) => (
        <span key={f} className="rounded-[5px] border border-border px-1.5 py-px text-[11px] font-semibold tracking-[0.04em] text-muted-foreground">
          {f}
        </span>
      ))}
    </span>
  );
}

/** A row of choices with a marker that glides to the active one */
function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
  const wrap = useRef<HTMLDivElement>(null);
  const [box, setBox] = useState<{ x: number; w: number } | null>(null);
  useEffect(() => {
    const el = wrap.current;
    if (!el) return;
    const measure = () => {
      const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
      if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [value]);
  const key = (e: React.KeyboardEvent) => {
    const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
    if (!d) return;
    e.preventDefault();
    const i = options.findIndex((o) => o.id === value);
    const n = options[(i + d + options.length) % options.length];
    onChange(n.id);
    wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
  };
  return (
    <div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
      {box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
      {options.map((o) => {
        const on = o.id === value;
        return (
          <button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 shrink-0 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* storefront                                                          */
/* ------------------------------------------------------------------ */

function ProductCard({ p, currency, t, bagged, onAdd }: { p: ShopProduct; currency: string; t: Texts; bagged: boolean; onAdd: () => void }) {
  const locale = useLocale();
  const spot = useSpotlight<HTMLElement>();
  return (
    <article onPointerMove={spot} className="kit-spotlight group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]">
      <a href={p.href ?? '#'} className={cn('relative block aspect-[4/3] overflow-hidden bg-muted', focusRing)} data-clip-ok aria-label={p.title}>
        <span className={cn('absolute inset-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.05] motion-reduce:transition-none', fit)}>{p.image}</span>
        {p.badge && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12px] font-semibold text-foreground backdrop-blur">{p.badge}</span>}
      </a>
      <div className="flex flex-1 flex-col p-4">
        <div className="flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground">
          <span>{p.kind}</span>
          {p.count && <span>{p.count}</span>}
        </div>
        <h3 className="mt-1 text-[16px] font-semibold leading-snug text-foreground">
          <a href={p.href ?? '#'} className="rounded-sm hover:underline hover:underline-offset-4">
            {p.title}
          </a>
        </h3>
        <div className="mt-2.5 flex items-center justify-between gap-3">
          <Formats formats={p.formats} />
          {p.rating !== undefined && (
            <span className="inline-flex items-center gap-1 text-[12.5px] tabular-nums text-muted-foreground" aria-label={fill(t.rating, { r: p.rating })}>
              <Icon name="star" size={13} className="fill-current text-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))]" />
              {new Intl.NumberFormat(TAG[locale], { minimumFractionDigits: 1 }).format(p.rating)}
            </span>
          )}
        </div>
        <div className="mt-auto flex items-center justify-between gap-3 pt-4">
          <span className="flex items-baseline gap-2">
            <span className="text-[17px] font-semibold tabular-nums text-foreground">{money(p.price, currency, locale)}</span>
            {p.was && <s className="text-[13px] tabular-nums text-muted-foreground">{money(p.was, currency, locale)}</s>}
          </span>
          <button type="button" onClick={onAdd} aria-pressed={bagged} className={cn('inline-flex min-h-9 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-[background-color,color] duration-300 max-sm:min-h-11', focusRing, bagged ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}>
            <span key={String(bagged)} className={cn('inline-flex', bagged && '[animation:kitshop-pop_.4s_var(--ease-kit)]')}>
              <Icon name={bagged ? 'check' : 'bag'} size={15} />
            </span>
            {bagged ? t.added : t.add}
          </button>
        </div>
      </div>
    </article>
  );
}

function Storefront({ products = [], currency = 'EUR', t }: CreatorShopProps & { t: Texts }) {
  const kinds = [...new Set(products.map((p) => p.kind))];
  const [kind, setKind] = useState('all');
  const [bag, setBag] = useState<string[]>([]);
  const shown = products.filter((p) => kind === 'all' || p.kind === kind);
  const [row, fade] = useScrollFade<HTMLDivElement>();
  return (
    <div>
      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
          <Segmented label={t.filter} value={kind} onChange={setKind} options={[{ id: 'all', label: t.all }, ...kinds.map((k) => ({ id: k, label: k }))]} />
        </div>
        <p className="inline-flex items-center gap-2 text-[13.5px] text-muted-foreground">
          <Icon name="download" size={15} />
          {t.instant}
          {bag.length > 0 && (
            <span key={bag.length} className="ml-2 inline-flex items-center gap-1.5 rounded-full bg-primary px-2.5 py-0.5 text-[12.5px] font-semibold text-primary-foreground [animation:kitshop-pop_.4s_var(--ease-kit)]" aria-live="polite">
              <Icon name="bag" size={13} />
              {bag.length}
            </span>
          )}
        </p>
      </div>
      <ul key={kind} className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
        {shown.map((p, i) => (
          <li key={p.id} className="[animation:kitshop-in_.5s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 60}ms` }}>
            <ProductCard p={p} currency={currency} t={t} bagged={bag.includes(p.id)} onAdd={() => setBag((b) => (b.includes(p.id) ? b.filter((x) => x !== p.id) : [...b, p.id]))} />
          </li>
        ))}
      </ul>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* presets                                                             */
/* ------------------------------------------------------------------ */

function Presets({ product, presets = [], photo, currency = 'EUR', t }: CreatorShopProps & { t: Texts }) {
  const locale = useLocale();
  const [look, setLook] = useState(presets[0]?.id ?? '');
  const [pos, setPos] = useState(50);
  const [ref, inView] = useInView<HTMLDivElement>();
  const frame = useRef<HTMLDivElement>(null);
  const drag = useRef(false);
  const chosen = presets.find((p) => p.id === look) ?? presets[0];
  const at = (clientX: number) => {
    const r = frame.current?.getBoundingClientRect();
    if (!r) return;
    setPos(Math.min(100, Math.max(0, round(((clientX - r.left) / r.width) * 100, 1))));
  };
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-12">
      <div ref={ref}>
        <div
          ref={frame}
          onPointerDown={(e) => {
            drag.current = true;
            e.currentTarget.setPointerCapture(e.pointerId);
            at(e.clientX);
          }}
          onPointerMove={(e) => drag.current && at(e.clientX)}
          onPointerUp={() => (drag.current = false)}
          onPointerCancel={() => (drag.current = false)}
          className="relative aspect-[4/3] cursor-ew-resize touch-pan-y select-none overflow-hidden rounded-xl border border-border bg-muted shadow-[var(--shadow-md)]"
          data-clip-ok
        >
          <div className={cn('absolute inset-0', fit)}>{photo}</div>
          <div key={chosen?.id} className={cn('absolute inset-0 [animation:kitshop-swap_.5s_var(--ease-kit)]', fit)} style={{ filter: chosen?.filter, clipPath: `inset(0 0 0 ${pos}%)` }}>
            {photo}
          </div>
          <span className="absolute left-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[12px] font-medium text-white backdrop-blur">{t.before}</span>
          <span className="absolute right-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[12px] font-medium text-white backdrop-blur">
            {t.after} · {chosen?.name}
          </span>
          <div aria-hidden className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.2)]" style={{ left: `${pos}%` }}>
            <span className={cn('absolute left-1/2 top-1/2 flex size-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white text-black shadow-[var(--shadow-lg)]', inView && pos === 50 && '[animation:kitshop-hint_1.6s_var(--ease-kit)_.6s_2]')}>
              <Icon name="arrows-left-right" size={18} />
            </span>
          </div>
          <input type="range" min={0} max={100} step={1} value={pos} onChange={(e) => setPos(Number(e.target.value))} aria-label={t.compare} className="absolute inset-0 size-full cursor-ew-resize opacity-0 focus-visible:opacity-0" />
        </div>
        <div role="radiogroup" aria-label={t.looks} onKeyDown={arrowKeys} className="kit-no-scrollbar mt-4 flex gap-3 overflow-x-auto pb-1">
          {presets.map((p) => {
            const on = p.id === chosen?.id;
            return (
              <button key={p.id} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => setLook(p.id)} className={cn('group w-[92px] shrink-0 text-left sm:w-[104px]', focusRing, 'rounded-lg')}>
                <span className={cn('relative block aspect-square overflow-hidden rounded-lg border-2 transition-[border-color,transform] duration-300', on ? 'border-primary' : 'border-transparent group-hover:-translate-y-0.5')} data-clip-ok>
                  <span className={cn('absolute inset-0 block', fit)} style={{ filter: p.filter }}>
                    {photo}
                  </span>
                </span>
                <span className={cn('mt-1.5 block truncate text-[13px] font-medium', on ? 'text-foreground' : 'text-muted-foreground')}>{p.name}</span>
              </button>
            );
          })}
        </div>
      </div>
      {product && (
        <div className="lg:self-center">
          <p className="text-[13px] font-medium text-muted-foreground">
            {product.kind}
            {product.count && ` · ${product.count}`}
          </p>
          <h3 className="mt-2 text-balance text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{product.title}</h3>
          {product.description && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{product.description}</p>}
          {product.points?.length ? (
            <ul className="mt-5 grid gap-2">
              {product.points.map((x) => (
                <li key={x} className="flex gap-2.5 text-[14.5px] text-foreground">
                  <Icon name="check" size={16} className="mt-[3px] text-primary" />
                  {x}
                </li>
              ))}
            </ul>
          ) : null}
          <div className="mt-6 flex items-center gap-3">
            <Formats formats={product.formats} />
          </div>
          <div className="mt-7 flex flex-wrap items-center gap-4 border-t border-border pt-6">
            <span className="flex items-baseline gap-2">
              <span className="text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{money(product.price, currency, locale)}</span>
              {product.was && <s className="text-[15px] tabular-nums text-muted-foreground">{money(product.was, currency, locale)}</s>}
            </span>
            <Magnetic className="max-sm:flex max-sm:w-full"><Button href={product.href ?? '#'} icon="arrow-right" className="max-sm:w-full">
              {fill(t.buy, { price: money(product.price, currency, locale) })}
            </Button></Magnetic>
          </div>
          <p className="mt-3 inline-flex items-center gap-2 text-[13px] text-muted-foreground">
            <Icon name="download" size={14} />
            {t.instant}
          </p>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* product                                                             */
/* ------------------------------------------------------------------ */

function FileTree({ files, depth = 0 }: { files: ShopFile[]; depth?: number }) {
  const [ref, inView] = useInView<HTMLUListElement>();
  return (
    <ul ref={ref} className={cn(depth > 0 && 'ml-[11px] border-l border-border pl-4')}>
      {files.map((f, i) => (
        <li key={f.name} className="transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ opacity: inView ? 1 : 0, transform: inView ? 'none' : 'translateX(-6px)', transitionDelay: inView ? `${depth * 120 + i * 60}ms` : '0ms' }}>
          <div className="flex min-h-9 items-center gap-2.5 text-[14px]">
            <Icon name={f.children ? 'folder-open' : 'file'} size={16} className={f.children ? 'text-primary' : 'text-muted-foreground'} />
            <span className={cn('min-w-0 flex-1 truncate', f.children ? 'font-medium text-foreground' : 'text-foreground/85')}>{f.name}</span>
            {f.size && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{f.size}</span>}
          </div>
          {f.children && <FileTree files={f.children} depth={depth + 1} />}
        </li>
      ))}
    </ul>
  );
}

const countFiles = (files: ShopFile[]): number => files.reduce((a, f) => a + (f.children ? countFiles(f.children) : 1), 0);

function Product({ product, files = [], licences = [], currency = 'EUR', onBuy, t }: CreatorShopProps & { t: Texts }) {
  const locale = useLocale();
  const [lic, setLic] = useState(licences[0]?.id ?? '');
  const l = licences.find((x) => x.id === lic) ?? licences[0];
  const price = l?.price ?? product?.price ?? 0;
  const base = useId();
  if (!product) return null;
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-12">
      <div className="grid content-start gap-6 max-lg:contents">
        <div className="relative aspect-[16/10] overflow-hidden rounded-xl border border-border bg-muted shadow-[var(--shadow-md)] max-lg:order-1" data-clip-ok>
          <div className={cn('absolute inset-0 kit-rise', fit)}>{product.image}</div>
        </div>
        {files.length > 0 && (
          <div className="kit-card p-5 sm:p-6 max-lg:order-3">
            <div className="mb-3 flex items-baseline justify-between gap-3">
              <h4 className="text-[15px] font-semibold text-foreground">{t.inside}</h4>
              <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.files, { n: countFiles(files) })}</span>
            </div>
            <FileTree files={files} />
          </div>
        )}
      </div>
      <div className="max-lg:order-2 lg:sticky lg:top-8 lg:self-start">
        <p className="text-[13px] font-medium text-muted-foreground">{product.kind}</p>
        <h3 className="mt-2 text-balance text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{product.title}</h3>
        {product.description && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{product.description}</p>}
        <div className="mt-4">
          <Formats formats={product.formats} />
        </div>
        {licences.length > 0 && (
          <fieldset className="mt-7">
            <legend className="mb-3 text-[14px] font-medium text-foreground">{t.licence}</legend>
            <div className="grid gap-2.5">
              {licences.map((x) => {
                const on = x.id === l?.id;
                return (
                  <label key={x.id} className={cn('flex cursor-pointer items-start gap-3 rounded-xl border p-4 transition-[border-color,background-color] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:border-border-strong')}>
                    <input type="radio" name={`${base}-l`} checked={on} onChange={() => setLic(x.id)} className="sr-only" />
                    <span aria-hidden className={cn('mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors', on ? 'border-primary' : 'border-border-strong')}>
                      <span className={cn('size-2 rounded-full bg-primary transition-transform duration-300', on ? 'scale-100' : 'scale-0')} />
                    </span>
                    <span className="min-w-0 flex-1">
                      <span className="block text-[15px] font-semibold text-foreground">{x.label}</span>
                      <span className="mt-0.5 block text-[13.5px] leading-snug text-muted-foreground">{x.detail}</span>
                    </span>
                    <span className="text-[15px] font-semibold tabular-nums text-foreground">{money(x.price, currency, locale)}</span>
                  </label>
                );
              })}
            </div>
          </fieldset>
        )}
        <Button type="button" icon="arrow-right" size="lg" className="mt-6 w-full" onClick={() => onBuy?.({ product: product.id, licence: l?.id, price })}>
          {t.buyShort} <span className="tabular-nums">
            <Money value={price} currency={currency} />
          </span>
        </Button>
        <ul className="mt-4 grid gap-1.5 text-[13px] text-muted-foreground">
          <li className="flex items-center gap-2">
            <Icon name="lock" size={14} />
            {t.secure}
          </li>
          <li className="flex items-center gap-2">
            <Icon name="refresh" size={14} />
            {t.lifetime}
          </li>
        </ul>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* bundle                                                              */
/* ------------------------------------------------------------------ */

function Bundle({ products = [], tiers = { 2: 10, 3: 20, 4: 30 }, currency = 'EUR', onBuy, t }: CreatorShopProps & { t: Texts }) {
  const locale = useLocale();
  const [picked, setPicked] = useState<string[]>(products.slice(0, 2).map((p) => p.id));
  const steps = Object.keys(tiers).map(Number).sort((a, b) => a - b);
  const n = picked.length;
  const pct = steps.filter((s) => s <= n).reduce((a, s) => tiers[s], 0);
  const next = steps.find((s) => s > n);
  const sum = products.filter((p) => picked.includes(p.id)).reduce((a, p) => a + p.price, 0);
  const total = Math.round(sum * (1 - pct / 100) * 100) / 100;
  const top = steps[steps.length - 1] ?? 1;
  const toggle = (id: string) => setPicked((v) => (v.includes(id) ? v.filter((x) => x !== id) : [...v, id]));
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-12">
      <fieldset>
        <legend className="mb-4 flex w-full items-baseline justify-between text-[14px] font-medium text-foreground">
          {t.choose}
          <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.selected, { n })}</span>
        </legend>
        <Stagger as="ul" step={60} className="grid grid-cols-1 gap-3 sm:grid-cols-2">
          {products.map((p) => {
            const on = picked.includes(p.id);
            return (
              <li key={p.id}>
                <label className={cn('group flex cursor-pointer items-center gap-4 rounded-xl border p-3 transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong')}>
                  <input type="checkbox" checked={on} onChange={() => toggle(p.id)} className="sr-only" />
                  <span className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted" data-clip-ok>
                    <span className={cn('absolute inset-0 block', fit)}>{p.image}</span>
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block text-[12.5px] text-muted-foreground">{p.kind}</span>
                    <span className="line-clamp-2 block text-[15px] font-semibold leading-snug text-foreground">{p.title}</span>
                    <span className="block text-[13.5px] tabular-nums text-muted-foreground">{money(p.price, currency, locale)}</span>
                  </span>
                  <span aria-hidden className={cn('flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
                    {on && <Icon name="check" size={14} strokeWidth={2.4} className="[animation:kitshop-pop_.35s_var(--ease-kit)]" />}
                  </span>
                </label>
              </li>
            );
          })}
        </Stagger>
      </fieldset>
      <aside className="lg:sticky lg:top-8 lg:self-start">
        <div className="kit-card overflow-hidden">
          <div className="p-6">
            <div className="relative h-2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" aria-hidden>
              <div className="absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${round(Math.min(1, n / top) * 100, 2)}%` }} />
              {steps.map((s) => (
                <span key={s} className={cn('absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors duration-500', s <= n ? 'border-primary bg-primary' : 'border-border-strong bg-card')} style={{ left: `${round((s / top) * 100, 2)}%` }} />
              ))}
            </div>
            <div className="relative mt-3 h-4 text-[11.5px] tabular-nums text-muted-foreground" aria-hidden>
              {steps.map((s) => (
                <span key={s} className={cn('absolute -translate-x-1/2 whitespace-nowrap', s === top && '-translate-x-full')} style={{ left: `${round((s / top) * 100, 2)}%` }}>
                  {locale === 'de' ? `${tiers[s]} %` : `${tiers[s]}%`}
                </span>
              ))}
            </div>
            <p key={`${n}`} aria-live="polite" className="mt-4 text-[14px] font-medium text-foreground [animation:kitshop-swap_.4s_var(--ease-kit)]">
              {next ? fill(t.nextTier, { n: next - n, p: tiers[next] }) : t.topTier}
            </p>
          </div>
          <div className="border-t border-border bg-surface p-6">
            <div className="flex items-end justify-between gap-4">
              <div>
                <p className="text-[13px] text-muted-foreground">{t.bundleTotal}</p>
                <p className="mt-1 text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">
                  <Money value={total} currency={currency} />
                </p>
              </div>
              {pct > 0 && (
                <span key={pct} className="rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-2.5 py-1 text-[13px] font-semibold text-success-foreground [animation:kitshop-pop_.4s_var(--ease-kit)]">
                  {fill(t.off, { p: pct })}
                </span>
              )}
            </div>
            {pct > 0 && (
              <p className="mt-2 text-[13px] text-muted-foreground">
                <s className="tabular-nums">{money(sum, currency, locale)}</s> · {fill(t.save, { v: money(Math.round((sum - total) * 100) / 100, currency, locale) })}
              </p>
            )}
            <Button type="button" icon="arrow-right" className="mt-5 w-full" disabled={!n} onClick={() => onBuy?.({ products: picked, total })}>
              {t.getBundle}
            </Button>
          </div>
        </div>
      </aside>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* pay-what-you-want                                                   */
/* ------------------------------------------------------------------ */

function PayWhatYouWant({ product, suggested = 8, max = 30, currency = 'EUR', onBuy, t }: CreatorShopProps & { t: Texts }) {
  const locale = useLocale();
  const [amount, setAmount] = useState(suggested);
  const [email, setEmail] = useState('');
  const [state, setState] = useState<'idle' | 'error' | 'done'>('idle');
  const id = useId();
  const pct = round((amount / max) * 100, 2);
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setState('error');
    onBuy?.({ product: product?.id, amount, email });
    setState('done');
  };
  if (!product) return null;
  return (
    <div className="kit-card mx-auto grid max-w-[960px] grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
      <div className="relative min-h-[260px] overflow-hidden bg-muted" data-clip-ok>
        <div className={cn('absolute inset-0 transition-transform duration-[1200ms] ease-[var(--ease-kit)]', fit)} style={{ transform: `scale(${round(1 + amount / max / 12, 3)})` }}>
          {product.image}
        </div>
        <span className="absolute left-4 top-4 rounded-full bg-background/90 px-3 py-1 text-[12.5px] font-semibold text-foreground backdrop-blur">{t.pwyw}</span>
      </div>
      <div className="p-6 sm:p-8">
        <p className="text-[13px] font-medium text-muted-foreground">
          {product.kind}
          {product.count && ` · ${product.count}`}
        </p>
        <h3 className="mt-2 text-balance text-[24px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{product.title}</h3>
        {product.description && <p className="mt-2.5 text-pretty text-[15px] leading-relaxed text-muted-foreground">{product.description}</p>}
        {state === 'done' ? (
          <p role="status" className="mt-8 flex items-start gap-2.5 rounded-xl bg-[color-mix(in_oklab,var(--success)_12%,transparent)] p-4 text-[14.5px] font-medium text-success-foreground [animation:kitshop-swap_.45s_var(--ease-kit)]">
            <Icon name="check-circle" size={18} className="mt-px" />
            {fill(t.sent, { e: email })}
          </p>
        ) : (
          <form onSubmit={submit} noValidate className="mt-7">
            <div className="flex items-end justify-between gap-4">
              <label htmlFor={`${id}-a`} className="text-[14px] font-medium text-foreground">
                {t.sliderLabel}
              </label>
              <span className="text-[34px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground" aria-live="polite">
                {amount === 0 ? t.free : money(amount, currency, locale)}
              </span>
            </div>
            <div className="relative mt-4">
              <div aria-hidden className="pointer-events-none absolute inset-x-3 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
                <div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
              </div>
              <input id={`${id}-a`} type="range" min={0} max={max} step={1} value={amount} onChange={(e) => setAmount(Number(e.target.value))} className="relative z-[1] h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
            </div>
            <div className="mt-2 grid grid-cols-4 gap-2">
              {[0, Math.round(suggested / 2), suggested, suggested * 2].filter((v, i, a) => a.indexOf(v) === i && v <= max).map((v) => (
                <button key={v} type="button" onClick={() => setAmount(v)} aria-pressed={amount === v} aria-label={v === suggested ? `${money(v, currency, locale)}, ${t.suggested}` : undefined} className={cn('relative inline-flex min-h-10 items-center justify-center gap-1 rounded-lg border px-2 text-[13px] font-medium tabular-nums transition-colors max-sm:min-h-11', focusRing, amount === v ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] font-semibold text-foreground' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground')}>
                  {v === 0 ? t.free : money(v, currency, locale)}
                  {v === suggested && <Icon name="star" size={12} className="fill-current opacity-80" />}
                </button>
              ))}
            </div>
            <p className="mt-2 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
              <Icon name="star" size={12} className="fill-current" />
              {t.suggested}
            </p>
            <p key={amount === 0 ? 'z' : 'p'} className="mt-4 min-h-5 text-[13.5px] text-muted-foreground [animation:kitshop-swap_.35s_var(--ease-kit)]">
              {amount === 0 ? t.zero : t.thanks}
            </p>
            <label htmlFor={`${id}-e`} className="mb-2 mt-6 block text-[14px] font-medium text-foreground">
              {t.email}
            </label>
            <input id={`${id}-e`} type="email" autoComplete="email" value={email} onChange={(e) => { setEmail(e.target.value); if (state === 'error') setState('idle'); }} placeholder={t.emailPh} aria-invalid={state === 'error'} aria-describedby={state === 'error' ? `${id}-err` : undefined} className={cn('h-11 w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing, state === 'error' ? 'border-destructive' : 'border-border-strong')} />
            {state === 'error' && (
              <p id={`${id}-err`} className="mt-1.5 text-[13px] text-destructive">
                {t.invalid}
              </p>
            )}
            <Button type="submit" icon="arrow-right" className="mt-5 w-full">
              {amount === 0 ? t.download : fill(t.downloadFor, { price: money(amount, currency, locale) })}
            </Button>
          </form>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* CreatorShop                                                         */
/* ------------------------------------------------------------------ */

/**
 * A shop for digital products by one creator.
 *   storefront         products by kind, file formats, add to bag
 *   presets            before and after slider, every look applied live
 *   product            files inside, licence choice, instant download
 *   bundle             pick products, the discount grows
 *   pay-what-you-want  a slider for an optional price, the download by email
 */
export function CreatorShop(props: CreatorShopProps) {
  const { variant = 'storefront', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'presets':
        return <Presets {...props} t={t} />;
      case 'product':
        return <Product {...props} t={t} />;
      case 'bundle':
        return <Bundle {...props} t={t} />;
      case 'pay-what-you-want':
        return <PayWhatYouWant {...props} t={t} />;
      default:
        return <Storefront {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'pay-what-you-want' ? 'glow' : variant === 'bundle' ? 'surface' : 'default'}>
      <ShopMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'pay-what-you-want' ? 'center' : 'left'} /> : null}
      {body}
    </Section>
  );
}

Frequent questions

How do I install Creator shop?

With aiw add @ai-website/creator-shop. The command puts components/ui/creator-shop.tsx into your project, the code is yours from then on. It brings along button, icon, motion and section. It needs no further npm packages.

Which variants does Creator shop have?

5 variants, Storefront, Presets with before and after, Digital product, Bundle builder and Pay what you want. You pick one with the variant prop, for example variant="presets".

Can I translate the texts of Creator shop?

Yes. Creator shop carries 39 texts of its own in English and German, such as “All”, “Filter by kind” and “Instant download”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Creator shop?

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

Can I adjust Creator shop without code?

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

Often searched

Jump to

Copied to the clipboard