Membership
A paid membership around one person’s work. Tiers as a ladder with yearly billing, a library that shows what each tier opens, a look inside the member area, monthly drops through the year and membership as a gift.
One of 3 components in Creators
Variants
Details
What Membership 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/membership.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 MembershipProps interface from components/ui/membership.tsx, with the comments from the code.
variantoptionalMembershipVarianttierslibraryinsidedropsgift
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodetiersoptionalMemberTier[]itemsoptionalMemberItem[]postsoptionalMemberPost[]membersoptionalnumberinside: members right now
hostoptional{ name: string; photo?: React.ReactNode }dropsoptionalMemberDrop[]currentoptionalstringdrops: today as YYYY-MM, otherwise the browser month after loading
brandoptionalstringgift: name on the card
currencyoptionalstringonSubmitoptional(data: Record<string, unknown>) => void
Built-in texts
Membership writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 39 texts
| Key | English | German |
|---|---|---|
| billing | English Billing | German Abrechnung |
| monthly | English Monthly | German Monatlich |
| yearly | English Yearly | German Jährlich |
| save | English 2 months free | German 2 Monate gratis |
| perMonth | English / month | German / Monat |
| billedYearly | English {p} billed once a year | German {p} einmal im Jahr |
| join | English Choose {name} | German {name} wählen |
| popular | English Most chosen | German Meistgewählt |
| everything | English Everything in {name}, plus | German Alles aus {name}, dazu |
| showFor | English Show what opens with | German Zeigen, was sich öffnet mit |
| free | English Free | German Kostenlos |
| open | English Open | German Offen |
| locked | English With {name} | German Mit {name} |
| opens | English {n} of {total} open | German {n} von {total} offen |
| unlock | English Unlock with {name} | German Mit {name} freischalten |
| members | English {n} members inside | German {n} Mitglieder sind dabei |
| readOn | English Become a member to read on | German Werde Mitglied, um weiterzulesen |
| latest | English Latest in the club | German Neu im Club |
| new | English New | German Neu |
| now | English Now | German Jetzt |
| soon | English Coming | German Demnächst |
| past | English Open the drop | German Paket öffnen |
| length | English Length | German Dauer |
| months | English {n} months | German {n} Monate |
| tier | English Tier | German Stufe |
| to | English For | German Für |
| toPh | English Name of the lucky one | German Name der beschenkten Person |
| message | English A few words | German Ein paar Worte |
| messagePh | English For the evenings you want to learn something new. | German Für die Abende, an denen du etwas Neues lernen willst. |
| English Their email | German E-Mail der beschenkten Person | |
| emailPh | English friend@example.com | German freundin@beispiel.de |
| when | English Send on | German Senden am |
| buy | English Give {length} for {price} | German {length} für {price} verschenken |
| sent | English Your gift is on its way. We let you know when it is opened. | German Dein Geschenk ist unterwegs. Wir sagen dir Bescheid, wenn es geöffnet wurde. |
| again | English Give another membership | German Weitere Mitgliedschaft verschenken |
| required | English Please fill in this field. | German Bitte fülle dieses Feld aus. |
| invalid | English Please enter an email address like name@example.com. | German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| gift | English Gift membership | German Geschenk-Mitgliedschaft |
| validFor | English {n} months of {tier} | German {n} Monate, Stufe {tier} |
Accessibility
Read from the source of the component.
- Membership handles the keys Arrow up, Arrow down, Arrow left and Arrow right itself.
- Sets aria-checked, aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 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, 701 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 { Magnetic, Stagger, useScrollFade, useScrollProgress } from '@/components/ui/motion';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* A paid membership around one person's work: tiers, the library behind
* them, a look inside, what each month brings and a membership as a gift.
*
* tiers tiers with monthly or yearly billing, what each one unlocks as a ladder
* library everything in the library, pick a tier and see what opens, locked items stay readable
* inside a look into the member area, the latest posts with a soft lock and a live count
* drops the year as a line of monthly drops, the current month lit, past ones ready to open
* gift a membership as a gift, length and tier, a card that updates while typing
*/
export type MembershipVariant = 'tiers' | 'library' | 'inside' | 'drops' | 'gift';
export interface MemberTier {
id: string;
name: string;
/** Per month */
monthly: number;
/** Per year */
yearly: number;
pitch?: string;
perks: string[];
featured?: boolean;
href?: string;
}
export interface MemberItem {
id: string;
title: string;
/** e.g. "Video", "Template", "Live call" */
kind: string;
icon?: IconName;
/** e.g. "24 min" */
meta?: string;
/** id of the lowest tier that unlocks it, missing means free */
tier?: string;
image?: React.ReactNode;
}
export interface MemberPost {
id: string;
title: string;
excerpt: string;
/** e.g. "2 days ago" */
when: string;
kind?: string;
locked?: boolean;
image?: React.ReactNode;
}
export interface MemberDrop {
/** YYYY-MM */
month: string;
title: string;
items: string[];
href?: string;
}
export interface MembershipProps {
variant?: MembershipVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
tiers?: MemberTier[];
items?: MemberItem[];
posts?: MemberPost[];
/** inside: members right now */
members?: number;
host?: { name: string; photo?: React.ReactNode };
drops?: MemberDrop[];
/** drops: today as YYYY-MM, otherwise the browser month after loading */
current?: string;
/** gift: name on the card */
brand?: string;
currency?: string;
onSubmit?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
billing: 'Billing',
monthly: 'Monthly',
yearly: 'Yearly',
save: '2 months free',
perMonth: '/ month',
billedYearly: '{p} billed once a year',
join: 'Choose {name}',
popular: 'Most chosen',
everything: 'Everything in {name}, plus',
showFor: 'Show what opens with',
free: 'Free',
open: 'Open',
locked: 'With {name}',
opens: '{n} of {total} open',
unlock: 'Unlock with {name}',
members: '{n} members inside',
readOn: 'Become a member to read on',
latest: 'Latest in the club',
new: 'New',
now: 'Now',
soon: 'Coming',
past: 'Open the drop',
length: 'Length',
months: '{n} months',
tier: 'Tier',
to: 'For',
toPh: 'Name of the lucky one',
message: 'A few words',
messagePh: 'For the evenings you want to learn something new.',
email: 'Their email',
emailPh: 'friend@example.com',
when: 'Send on',
buy: 'Give {length} for {price}',
sent: 'Your gift is on its way. We let you know when it is opened.',
again: 'Give another membership',
required: 'Please fill in this field.',
invalid: 'Please enter an email address like name@example.com.',
gift: 'Gift membership',
validFor: '{n} months of {tier}',
},
de: {
billing: 'Abrechnung',
monthly: 'Monatlich',
yearly: 'Jährlich',
save: '2 Monate gratis',
perMonth: '/ Monat',
billedYearly: '{p} einmal im Jahr',
join: '{name} wählen',
popular: 'Meistgewählt',
everything: 'Alles aus {name}, dazu',
showFor: 'Zeigen, was sich öffnet mit',
free: 'Kostenlos',
open: 'Offen',
locked: 'Mit {name}',
opens: '{n} von {total} offen',
unlock: 'Mit {name} freischalten',
members: '{n} Mitglieder sind dabei',
readOn: 'Werde Mitglied, um weiterzulesen',
latest: 'Neu im Club',
new: 'Neu',
now: 'Jetzt',
soon: 'Demnächst',
past: 'Paket öffnen',
length: 'Dauer',
months: '{n} Monate',
tier: 'Stufe',
to: 'Für',
toPh: 'Name der beschenkten Person',
message: 'Ein paar Worte',
messagePh: 'Für die Abende, an denen du etwas Neues lernen willst.',
email: 'E-Mail der beschenkten Person',
emailPh: 'freundin@beispiel.de',
when: 'Senden am',
buy: '{length} für {price} verschenken',
sent: 'Dein Geschenk ist unterwegs. Wir sagen dir Bescheid, wenn es geöffnet wurde.',
again: 'Weitere Mitgliedschaft verschenken',
required: 'Bitte fülle dieses Feld aus.',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
gift: 'Geschenk-Mitgliedschaft',
validFor: '{n} Monate, Stufe {tier}',
},
};
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);
}
const mfmt = (ym: string, locale: Locale, o: Intl.DateTimeFormatOptions) => {
const [y, m] = ym.split('-').map(Number);
return new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(new Date(Date.UTC(y, m - 1, 1)));
};
const MOTION_CSS = `
@keyframes kitmem-in{from{opacity:0;transform:translateY(10px)}}
@keyframes kitmem-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitmem-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.15);opacity:1}100%{transform:scale(1)}}
@keyframes kitmem-unlock{0%{transform:rotate(0)}30%{transform:rotate(-14deg)}60%{transform:rotate(10deg)}100%{transform:rotate(0)}}
@media (prefers-reduced-motion:reduce){[class*="kitmem-"],.kitmem-anim{animation:none!important;transition:none!important}}`;
function MemMotion() {
return (
<style href="kitmem-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
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 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 flex-1 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11 sm:flex-none', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* tiers */
/* ------------------------------------------------------------------ */
function TierPrice({ tier, yearly, currency, t }: { tier: MemberTier; yearly: boolean; currency: string; t: Texts }) {
const locale = useLocale();
const v = useTween(yearly ? tier.yearly / 12 : tier.monthly);
return (
<div>
<p className="flex items-baseline gap-1.5">
<span className="text-[38px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{money(Math.round(v * 100) / 100, currency, locale)}</span>
<span className="text-[14px] text-muted-foreground">{t.perMonth}</span>
</p>
<p key={String(yearly)} className="mt-1.5 min-h-5 text-[13px] text-muted-foreground [animation:kitmem-in_.35s_var(--ease-kit)]">
{yearly ? fill(t.billedYearly, { p: money(tier.yearly, currency, locale) }) : ' '}
</p>
</div>
);
}
function Tiers({ tiers = [], currency = 'EUR', t }: MembershipProps & { t: Texts }) {
const [bill, setBill] = useState('yearly');
const spot = useSpotlight<HTMLElement>();
return (
<div>
<div className="mb-10 flex justify-center">
<Segmented label={t.billing} value={bill} onChange={setBill} options={[{ id: 'monthly', label: t.monthly }, { id: 'yearly', label: <>{t.yearly}<span className="rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] px-2 py-0.5 text-[11.5px] font-semibold text-success-foreground">{t.save}</span></> }]} />
</div>
<Stagger as="ul" step={90} className={cn('grid grid-cols-1 items-stretch gap-5 md:grid-cols-2', tiers.length >= 3 && 'lg:grid-cols-3')}>
{tiers.map((tier, i) => (
<li key={tier.id} className={cn('flex', tiers.length === 3 && i === 2 && 'md:col-span-2 lg:col-span-1')}>
<article onPointerMove={spot} className={cn('kit-spotlight relative flex w-full flex-col rounded-2xl border p-6 transition-transform duration-300 hover:-translate-y-1 sm:p-8', tier.featured ? 'kit-beam border-transparent bg-card shadow-[var(--highlight),var(--shadow-lg)]' : 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]')}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-[20px] font-semibold tracking-[-0.01em] text-foreground">{tier.name}</h3>
{tier.featured && <span className="rounded-full bg-primary px-2.5 py-1 text-[12px] font-semibold text-primary-foreground">{t.popular}</span>}
</div>
{tier.pitch && <p className="mt-2 min-h-[2.8em] text-pretty text-[14.5px] leading-snug text-muted-foreground">{tier.pitch}</p>}
<div className="mt-5">
<TierPrice tier={tier} yearly={bill === 'yearly'} currency={currency} t={t} />
</div>
<div className="mt-6 border-t border-border pt-6">
{i > 0 ? <p className="mb-3 text-[13px] font-medium text-muted-foreground">{fill(t.everything, { name: tiers[i - 1].name })}</p> : <p aria-hidden className="mb-3 hidden text-[13px] md:block"> </p>}
<ul className="grid gap-2.5">
{tier.perks.map((p) => (
<li key={p} className="flex gap-2.5 text-[14.5px] text-foreground">
<Icon name="check" size={16} className="mt-[3px] text-primary" />
{p}
</li>
))}
</ul>
</div>
<div className="mt-auto pt-8">
<Button href={tier.href ?? '#'} variant={tier.featured ? 'primary' : 'outline'} icon="arrow-right" className="w-full">
{fill(t.join, { name: tier.name })}
</Button>
</div>
</article>
</li>
))}
</Stagger>
</div>
);
}
/* ------------------------------------------------------------------ */
/* library */
/* ------------------------------------------------------------------ */
function Library({ tiers = [], items = [], t }: MembershipProps & { t: Texts }) {
const [tier, setTier] = useState(tiers[0]?.id ?? '');
const rank = (id?: string) => (id ? tiers.findIndex((x) => x.id === id) : -1);
const level = rank(tier);
const isOpen = (it: MemberItem) => rank(it.tier) <= level;
const n = items.filter(isOpen).length;
const pct = round((n / Math.max(1, items.length)) * 100, 2);
const [row, fade] = useScrollFade<HTMLDivElement>();
const nameOf = (id?: string) => tiers.find((x) => x.id === id)?.name ?? '';
return (
<div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-[13px] font-medium text-muted-foreground">{t.showFor}</p>
<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.showFor} value={tier} onChange={setTier} options={tiers.map((x) => ({ id: x.id, label: x.name }))} />
</div>
</div>
<div className="sm:w-[240px]" aria-live="polite">
<p className="text-[14px] font-semibold tabular-nums text-foreground">{fill(t.opens, { n, total: items.length })}</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
<div className="h-full rounded-full bg-primary transition-[width] duration-700 ease-[var(--ease-kit)]" style={{ width: `${pct}%` }} />
</div>
</div>
</div>
<ul className="mt-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{items.map((it, i) => {
const on = isOpen(it);
return (
<li key={it.id} className="[animation:kitmem-in_.5s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 40}ms` }}>
<article className={cn('group relative flex h-full flex-col overflow-hidden rounded-xl border bg-card transition-[border-color,box-shadow,transform] duration-500', on ? 'border-border shadow-[var(--highlight),var(--shadow-sm)] hover:-translate-y-0.5' : 'border-dashed border-border-strong')}>
<div className="relative aspect-[16/10] overflow-hidden bg-muted" data-clip-ok>
<div className={cn('absolute inset-0 transition-[filter,transform,opacity] duration-700 ease-[var(--ease-kit)]', fit, on ? 'group-hover:scale-[1.03]' : 'scale-105 opacity-60 blur-[6px] grayscale')}>{it.image}</div>
<span key={`${it.id}-${on}`} className={cn('absolute right-2 top-2 flex size-8 items-center sm:right-3 sm:top-3 sm:size-9 justify-center rounded-full backdrop-blur', on ? 'bg-[color-mix(in_oklab,var(--success)_85%,transparent)] text-white [animation:kitmem-pop_.45s_var(--ease-kit)]' : 'bg-black/50 text-white')}>
<Icon name={on ? 'unlock' : 'lock'} size={16} className={on ? '[animation:kitmem-unlock_.6s_var(--ease-kit)]' : undefined} />
</span>
</div>
<div className="flex flex-1 flex-col p-3 sm:p-4">
<p className="flex flex-wrap items-center gap-x-1.5 text-[12.5px] text-muted-foreground">
<Icon name={it.icon ?? 'file'} size={13} />
{it.kind}
{it.meta && ` · ${it.meta}`}
</p>
<h3 className="mt-1 text-[14.5px] font-semibold leading-snug text-foreground sm:text-[15.5px]">{it.title}</h3>
<p className={cn('mt-auto pt-3 text-[12.5px] font-medium', on ? 'text-success-foreground' : 'text-muted-foreground')}>{on ? (it.tier ? t.open : t.free) : fill(t.locked, { name: nameOf(it.tier) })}</p>
</div>
</article>
</li>
);
})}
</ul>
</div>
);
}
/* ------------------------------------------------------------------ */
/* inside */
/* ------------------------------------------------------------------ */
function Inside({ posts = [], members = 0, host, tiers = [], currency = 'EUR', t }: MembershipProps & { t: Texts }) {
const locale = useLocale();
const [count, setCount] = useState(members);
useEffect(() => {
if (prefersReducedMotion() || !members) return;
const id = window.setInterval(() => setCount((c) => c + (Math.random() < 0.5 ? 1 : 0)), 3200);
return () => window.clearInterval(id);
}, [members]);
const cheapest = tiers[0];
const [ref, inView] = useInView<HTMLDivElement>();
return (
<div ref={ref} className="mx-auto max-w-[920px]">
<div className="kit-card overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-border bg-surface px-5 py-3.5">
<div className="flex items-center gap-3">
{host?.photo && (
<span className="size-9 overflow-hidden rounded-full bg-muted" data-clip-ok>
<span className={cn('block size-full', fit)}>{host.photo}</span>
</span>
)}
<span>
<span className="block text-[14.5px] font-semibold text-foreground">{host?.name}</span>
<span className="block text-[12.5px] text-muted-foreground">{t.latest}</span>
</span>
</div>
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-3 py-1 text-[12.5px] font-medium text-success-foreground" aria-live="polite">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-success" />
</span>
<span key={count} className="tabular-nums [animation:kitmem-in_.3s_var(--ease-kit)]">
{fill(t.members, { n: new Intl.NumberFormat(TAG[locale]).format(count) })}
</span>
</span>
</div>
<ol className="relative divide-y divide-border">
{posts.map((p, i) => (
<li key={p.id} className={cn('grid gap-4 p-5 transition-[opacity,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none sm:p-6', p.image ? 'grid-cols-[minmax(0,1fr)_88px] sm:grid-cols-[minmax(0,1fr)_180px]' : 'grid-cols-1')} style={{ opacity: inView ? 1 : 0, transform: inView ? 'none' : 'translateY(12px)', transitionDelay: inView ? `${i * 110}ms` : '0ms' }}>
<div className="min-w-0">
<p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
{i === 0 && <span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground">{t.new}</span>}
{p.kind && <span>{p.kind}</span>}
<span aria-hidden>·</span>
<span>{p.when}</span>
</p>
<h3 className="mt-1.5 text-[18px] font-semibold leading-snug tracking-[-0.01em] text-foreground">{p.title}</h3>
<div className="relative mt-2">
<p className={cn('text-pretty text-[14.5px] leading-relaxed text-muted-foreground', p.locked && 'select-none [mask-image:linear-gradient(to_bottom,black_30%,transparent)]')}>{p.excerpt}</p>
{p.locked && (
<p className="mt-1 inline-flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="lock" size={14} className="text-primary" />
{t.readOn}
</p>
)}
</div>
</div>
{p.image && (
<div className="relative aspect-square self-start overflow-hidden rounded-lg bg-muted sm:aspect-[16/10]" data-clip-ok>
<div className={cn('absolute inset-0', fit, p.locked && 'blur-[5px]')}>{p.image}</div>
</div>
)}
</li>
))}
</ol>
{cheapest && (
<div className="flex flex-col items-center gap-3 border-t border-border bg-surface px-6 py-6 text-center sm:flex-row sm:justify-between sm:text-left">
<p className="text-[15px] text-foreground">
<span className="font-semibold">{cheapest.name}</span>
<span className="text-muted-foreground"> · {money(cheapest.monthly, currency, locale)} {t.perMonth}</span>
</p>
<Magnetic className="max-sm:flex max-sm:w-full"><Button href={cheapest.href ?? '#'} icon="arrow-right" className="max-sm:w-full">
{fill(t.join, { name: cheapest.name })}
</Button></Magnetic>
</div>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* drops */
/* ------------------------------------------------------------------ */
function Drops({ drops = [], current, t }: MembershipProps & { t: Texts }) {
const locale = useLocale();
const [now, setNow] = useState(current ?? '');
useEffect(() => {
if (current) return;
const d = new Date();
setNow(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
}, [current]);
const line = useScrollProgress<HTMLOListElement>({ start: 0.1, end: 0.6 });
const [row, fade] = useScrollFade<HTMLDivElement>();
// On phones the row starts at the current month, earlier months are a swipe back
useEffect(() => {
const el = row.current;
const card = el?.querySelector<HTMLElement>('[data-now]');
if (!el || !card || el.scrollWidth <= el.clientWidth) return;
el.scrollLeft = Math.max(0, card.offsetLeft - 24);
}, [now, row]);
return (
<div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 pb-2 lg:mx-0 lg:overflow-visible lg:px-0', fade)}>
<ol ref={line} className="relative flex min-w-max gap-4 pt-10 lg:grid lg:min-w-0 lg:grid-cols-6 lg:gap-5">
<span aria-hidden className="absolute left-0 right-0 top-[19px] h-px bg-border" />
<span aria-hidden className="kitmem-anim absolute left-0 right-0 top-[19px] h-px origin-left bg-primary" style={{ transform: 'scaleX(var(--kit-progress, 1))' }} />
{drops.map((d, i) => {
const state = !now ? 'past' : d.month < now ? 'past' : d.month === now ? 'now' : 'soon';
return (
<li key={d.month} data-now={state === 'now' || undefined} className="relative w-[230px] shrink-0 lg:w-auto">
<span aria-hidden className={cn('absolute -top-[26px] left-0 size-3 rounded-full border-2 bg-background', state === 'now' ? 'border-primary bg-primary shadow-[0_0_0_6px_color-mix(in_oklab,var(--primary)_18%,transparent)]' : state === 'past' ? 'border-primary' : 'border-border-strong')} />
<article className={cn('flex h-full flex-col rounded-xl border p-4 transition-[transform,border-color] duration-300 [animation:kitmem-in_.5s_var(--ease-kit)_both]', state === 'now' ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : state === 'soon' ? 'border-dashed border-border-strong' : 'border-border bg-card hover:-translate-y-0.5')} style={{ animationDelay: `${i * 70}ms` }}>
<p className={cn('text-[12.5px] font-semibold', state === 'now' ? 'text-primary' : 'text-muted-foreground')}>
{mfmt(d.month, locale, { month: 'long', year: 'numeric' })}
{state === 'now' && ` · ${t.now}`}
</p>
<h3 className="mt-1.5 text-[16px] font-semibold leading-snug text-foreground">{d.title}</h3>
<ul className="mt-3 grid gap-1.5">
{d.items.map((x) => (
<li key={x} className={cn('flex gap-2 text-[13.5px]', state === 'soon' ? 'text-muted-foreground' : 'text-foreground/90')}>
<span aria-hidden className={cn('mt-[0.55em] size-1 shrink-0 rounded-full', state === 'soon' ? 'bg-border-strong' : 'bg-primary')} />
{x}
</li>
))}
</ul>
<div className="mt-auto pt-4">
{state === 'soon' ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<Icon name="clock" size={13} />
{t.soon}
</span>
) : (
<a href={d.href ?? '#'} className={cn('inline-flex min-h-11 sm:min-h-9 items-center gap-1.5 rounded-md text-[13px] font-medium text-foreground hover:underline hover:underline-offset-4', focusRing)}>
{t.past}
<Icon name="arrow-right" size={14} />
</a>
)}
</div>
</article>
</li>
);
})}
</ol>
</div>
);
}
/* ------------------------------------------------------------------ */
/* gift */
/* ------------------------------------------------------------------ */
function Gift({ tiers = [], brand, currency = 'EUR', onSubmit, t }: MembershipProps & { t: Texts }) {
const locale = useLocale();
const base = useId();
const [months, setMonths] = useState('6');
const [tier, setTier] = useState(tiers[0]?.id ?? '');
const [to, setTo] = useState('');
const [msg, setMsg] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [sent, setSent] = useState(false);
const tr = tiers.find((x) => x.id === tier) ?? tiers[0];
const n = Number(months);
const price = tr ? (n === 12 ? tr.yearly : tr.monthly * n) : 0;
const pv = useTween(price);
const card = useRef<HTMLDivElement>(null);
const tilt = (e: React.PointerEvent<HTMLDivElement>) => {
const el = card.current;
if (!el || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
const y = (e.clientY - r.top) / r.height - 0.5;
el.style.transform = `perspective(1000px) rotateY(${(x * 10).toFixed(2)}deg) rotateX(${(-y * 8).toFixed(2)}deg)`;
};
const submit = (e: React.FormEvent) => {
e.preventDefault();
const next: Record<string, string> = {};
if (!to.trim()) next.to = t.required;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = t.invalid;
setErrors(next);
if (Object.keys(next).length) return document.getElementById(`${base}-${Object.keys(next)[0]}`)?.focus();
onSubmit?.({ months: n, tier, to, msg, email, price });
setSent(true);
};
const field = 'h-11 w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground';
return (
<div className="grid grid-cols-1 items-start gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
<div className="lg:sticky lg:top-8">
<div onPointerMove={tilt} onPointerLeave={() => card.current && (card.current.style.transform = '')} className="mx-auto max-w-[460px]">
<div ref={card} className="relative aspect-[1.6] overflow-hidden rounded-2xl bg-[linear-gradient(135deg,color-mix(in_oklab,var(--primary)_85%,#000),color-mix(in_oklab,var(--primary)_45%,#0b0b12))] p-6 text-white shadow-[0_30px_60px_-24px_color-mix(in_oklab,var(--primary)_60%,transparent)] transition-transform duration-500 ease-[var(--ease-kit)] sm:p-8">
<span aria-hidden data-clip-ok className="absolute -right-16 -top-16 size-56 rounded-full border border-white/15" />
<span aria-hidden data-clip-ok className="absolute -right-6 -top-6 size-36 rounded-full border border-white/20" />
<div className="relative flex h-full flex-col">
<p className="text-[13px] font-medium uppercase tracking-[0.14em] text-white/75">{t.gift}</p>
<p className="mt-1 text-[22px] font-semibold tracking-[-0.01em] sm:text-[26px]">{brand}</p>
<p key={`${n}-${tier}`} className="mt-auto text-[15px] text-white/90 [animation:kitmem-swap_.35s_var(--ease-kit)]">
{fill(t.validFor, { n, tier: tr?.name ?? '' })}
</p>
<p className="mt-1 min-h-[1.5em] truncate text-[19px] font-semibold">{to ? `${t.to} ${to}` : ' '}</p>
{msg && <p className="mt-1 line-clamp-2 text-[13px] italic text-white/80">{msg}</p>}
</div>
</div>
</div>
</div>
{sent ? (
<div role="status" className="kit-card flex flex-col items-center p-8 text-center [animation:kitmem-swap_.5s_var(--ease-kit)]">
<span className="flex size-14 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success [animation:kitmem-pop_.5s_var(--ease-kit)]">
<Icon name="gift" size={24} />
</span>
<p className="mt-5 max-w-[24rem] text-pretty text-[16px] text-foreground">{t.sent}</p>
<Button variant="outline" className="mt-6" onClick={() => setSent(false)}>
{t.again}
</Button>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6">
<div>
<p className="mb-2.5 text-[14px] font-medium text-foreground">{t.length}</p>
<Segmented label={t.length} value={months} onChange={setMonths} options={['3', '6', '12'].map((m) => ({ id: m, label: fill(t.months, { n: m }) }))} className="flex w-full [&>button]:flex-1" />
</div>
{tiers.length > 1 && (
<div>
<p className="mb-2.5 text-[14px] font-medium text-foreground">{t.tier}</p>
<Segmented label={t.tier} value={tier} onChange={setTier} options={tiers.map((x) => ({ id: x.id, label: x.name }))} className="flex w-full [&>button]:flex-1" />
</div>
)}
<div>
<label htmlFor={`${base}-to`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.to}
</label>
<input id={`${base}-to`} value={to} maxLength={32} onChange={(e) => setTo(e.target.value)} placeholder={t.toPh} aria-invalid={!!errors.to} aria-describedby={errors.to ? `${base}-to-e` : undefined} className={cn(field, errors.to ? 'border-destructive' : 'border-border-strong', focusRing)} />
{errors.to && (
<p id={`${base}-to-e`} className="mt-1.5 text-[13px] text-destructive">
{errors.to}
</p>
)}
</div>
<div>
<label htmlFor={`${base}-msg`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.message}
</label>
<textarea id={`${base}-msg`} rows={2} maxLength={120} value={msg} onChange={(e) => setMsg(e.target.value)} placeholder={t.messagePh} className={cn('w-full resize-none rounded-lg border border-border-strong bg-background px-3 py-2.5 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing)} />
</div>
<div>
<label htmlFor={`${base}-email`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.email}
</label>
<input id={`${base}-email`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t.emailPh} aria-invalid={!!errors.email} aria-describedby={errors.email ? `${base}-email-e` : undefined} className={cn(field, errors.email ? 'border-destructive' : 'border-border-strong', focusRing)} />
{errors.email && (
<p id={`${base}-email-e`} className="mt-1.5 text-[13px] text-destructive">
{errors.email}
</p>
)}
</div>
<Button type="submit" icon="gift" size="lg" className="w-full">
{fill(t.buy, { length: fill(t.months, { n }), price: money(Math.round(pv * 100) / 100, currency, locale) })}
</Button>
</form>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Membership */
/* ------------------------------------------------------------------ */
/**
* A membership around one person's work.
* tiers tiers with monthly or yearly billing as a ladder
* library the library, pick a tier to see what opens
* inside a look into the member area
* drops the monthly drops on a line through the year
* gift a membership as a gift with a live card
*/
export function Membership(props: MembershipProps) {
const { variant = 'tiers', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'library':
return <Library {...props} t={t} />;
case 'inside':
return <Inside {...props} t={t} />;
case 'drops':
return <Drops {...props} t={t} />;
case 'gift':
return <Gift {...props} t={t} />;
default:
return <Tiers {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'tiers' ? 'glow' : variant === 'inside' ? 'surface' : 'default'}>
<MemMotion />
{title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'tiers' || variant === 'inside' ? 'center' : 'left'} /> : null}
{body}
</Section>
);
}Frequent questions
How do I install Membership?
With aiw add @ai-website/membership. The command puts components/ui/membership.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 Membership have?
5 variants, Tiers, Library, A look inside, Monthly drops and Gift membership. You pick one with the variant prop, for example variant="library".
Can I translate the texts of Membership?
Yes. Membership carries 39 texts of its own in English and German, such as “Billing”, “Monthly” and “Yearly”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Membership?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Membership with aiw_components and install it with aiw_add @ai-website/membership. Name the variant in your prompt, for example variant="tiers", and the assistant writes your content straight into the props.
Can I adjust Membership without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Membership code to copy.