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
Variants
Details
What Creator shop brings along, read from the registry and the source.
- Category
- Creators
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/creator-shop.tsx
- npm packages
- None
- Languages
- English and German, 39 texts of its own
- Studio
- All 5 variants editable
- In collections
- Creator page
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CreatorShopProps interface from components/ui/creator-shop.tsx, with the comments from the code.
variantoptionalCreatorShopVariantstorefrontpresetsproductbundlepay-what-you-want
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeproductsoptionalShopProduct[]productoptionalShopProduct & { description?: string; points?: string[] }presets, product, pay-what-you-want: the product shown
presetsoptionalShopPreset[]photooptionalReact.ReactNodepresets: the photo the looks are applied to
filesoptionalShopFile[]licencesoptionalShopLicence[]tiersoptionalRecord<number, number>bundle: discount in percent by number of products, e.g. { 2: 10, 3: 20, 4: 30 }
suggestedoptionalnumberpay-what-you-want: suggested amounts and the top of the slider
maxoptionalnumbercurrencyoptionalstringonBuyoptional(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
| Key | English | German |
|---|---|---|
| all | English All | German Alle |
| filter | English Filter by kind | German Nach Art filtern |
| instant | English Instant download | German Sofort-Download |
| add | English Add to bag | German In den Korb |
| added | English In your bag | German Im Warenkorb |
| before | English Before | German Vorher |
| after | English After | German Nachher |
| compare | English Drag to compare before and after | German Ziehen, um vorher und nachher zu vergleichen |
| looks | English Looks | German Looks |
| buy | English Buy for {price} | German Für {price} kaufen |
| buyShort | English Buy for | German Kaufen für |
| inside | English What is inside | German Was drin ist |
| files | English {n} files | German {n} Dateien |
| licence | English Licence | German Lizenz |
| secure | English Paid securely, files arrive in seconds | German Sicher bezahlt, Dateien kommen in Sekunden |
| lifetime | English Updates for life | German Updates für immer |
| choose | English Choose products | German Produkte wählen |
| selected | English {n} selected | German {n} gewählt |
| bundleTotal | English Bundle price | German Preis fürs Paket |
| save | English You save {v} | German Du sparst {v} |
| nextTier | English Add {n} more for {p}% off | German Noch {n} dazu für {p} % Rabatt |
| topTier | English Biggest discount reached | German Höchster Rabatt erreicht |
| getBundle | English Get the bundle | German Paket holen |
| off | English {p}% off | German {p} % Rabatt |
| pwyw | English Pay what you want | German Zahl, was du willst |
| free | English Free | German Kostenlos |
| name | English Price | German Preis |
| sliderLabel | English Your price | German Dein Preis |
| thanks | English Thank you, that helps me make the next one. | German Danke, das hilft mir beim nächsten. |
| zero | English Free is fine. Enjoy it. | German Kostenlos ist völlig in Ordnung. Viel Freude damit. |
| English Email for the download | German E-Mail für den Download | |
| emailPh | English you@example.com | German du@beispiel.de |
| download | English Send me the download | German Download schicken |
| downloadFor | English Pay {price} and download | German {price} zahlen und herunterladen |
| invalid | English Please enter an email address like name@example.com. | German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| sent | English The download link is on its way to {e}. | German Der Download-Link ist unterwegs an {e}. |
| suggested | English Suggested | German Vorschlag |
| sales | English {n} sold | German {n} verkauft |
| rating | English {r} of 5 stars | German {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
'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.