Mitgliedschaft
Eine bezahlte Mitgliedschaft rund um die Arbeit einer Person. Stufen als Leiter mit Jahresabrechnung, eine Bibliothek, die zeigt, was jede Stufe öffnet, ein Blick in den Mitgliederbereich, monatliche Pakete übers Jahr und die Mitgliedschaft als Geschenk.
Eine von 3 Components in Creator
Variants
Details
Was Mitgliedschaft mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Creator
- Bereich
- Industries
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/membership.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 39 eigene Texte
- In Sammlungen
- Creator-Seite
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MembershipProps aus components/ui/membership.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Mitgliedschaft selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 39 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| billing | Englisch Billing | Deutsch Abrechnung |
| monthly | Englisch Monthly | Deutsch Monatlich |
| yearly | Englisch Yearly | Deutsch Jährlich |
| save | Englisch 2 months free | Deutsch 2 Monate gratis |
| perMonth | Englisch / month | Deutsch / Monat |
| billedYearly | Englisch {p} billed once a year | Deutsch {p} einmal im Jahr |
| join | Englisch Choose {name} | Deutsch {name} wählen |
| popular | Englisch Most chosen | Deutsch Meistgewählt |
| everything | Englisch Everything in {name}, plus | Deutsch Alles aus {name}, dazu |
| showFor | Englisch Show what opens with | Deutsch Zeigen, was sich öffnet mit |
| free | Englisch Free | Deutsch Kostenlos |
| open | Englisch Open | Deutsch Offen |
| locked | Englisch With {name} | Deutsch Mit {name} |
| opens | Englisch {n} of {total} open | Deutsch {n} von {total} offen |
| unlock | Englisch Unlock with {name} | Deutsch Mit {name} freischalten |
| members | Englisch {n} members inside | Deutsch {n} Mitglieder sind dabei |
| readOn | Englisch Become a member to read on | Deutsch Werde Mitglied, um weiterzulesen |
| latest | Englisch Latest in the club | Deutsch Neu im Club |
| new | Englisch New | Deutsch Neu |
| now | Englisch Now | Deutsch Jetzt |
| soon | Englisch Coming | Deutsch Demnächst |
| past | Englisch Open the drop | Deutsch Paket öffnen |
| length | Englisch Length | Deutsch Dauer |
| months | Englisch {n} months | Deutsch {n} Monate |
| tier | Englisch Tier | Deutsch Stufe |
| to | Englisch For | Deutsch Für |
| toPh | Englisch Name of the lucky one | Deutsch Name der beschenkten Person |
| message | Englisch A few words | Deutsch Ein paar Worte |
| messagePh | Englisch For the evenings you want to learn something new. | Deutsch Für die Abende, an denen du etwas Neues lernen willst. |
| Englisch Their email | Deutsch E-Mail der beschenkten Person | |
| emailPh | Englisch friend@example.com | Deutsch freundin@beispiel.de |
| when | Englisch Send on | Deutsch Senden am |
| buy | Englisch Give {length} for {price} | Deutsch {length} für {price} verschenken |
| sent | Englisch Your gift is on its way. We let you know when it is opened. | Deutsch Dein Geschenk ist unterwegs. Wir sagen dir Bescheid, wenn es geöffnet wurde. |
| again | Englisch Give another membership | Deutsch Weitere Mitgliedschaft verschenken |
| required | Englisch Please fill in this field. | Deutsch Bitte fülle dieses Feld aus. |
| invalid | Englisch Please enter an email address like name@example.com. | Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| gift | Englisch Gift membership | Deutsch Geschenk-Mitgliedschaft |
| validFor | Englisch {n} months of {tier} | Deutsch {n} Monate, Stufe {tier} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Mitgliedschaft reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links und Pfeil rechts.
- Setzt aria-checked, aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 701 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Mitgliedschaft?
Mit aiw add @ai-website/membership. Der Befehl legt die Datei components/ui/membership.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Mitgliedschaft?
5 Varianten, Stufen, Bibliothek, Blick hinein, Monatspakete und Mitgliedschaft verschenken. Du wählst sie mit der Prop variant, zum Beispiel variant="library".
Kann ich die Texte von Mitgliedschaft übersetzen?
Ja. Mitgliedschaft bringt 39 eigene Texte auf Deutsch und Englisch mit, etwa „Abrechnung“, „Monatlich“ und „Jährlich“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Mitgliedschaft einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Mitgliedschaft mit aiw_components und installieren sie mit aiw_add @ai-website/membership. Nenn im Prompt die Variante, etwa variant="tiers", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Mitgliedschaft ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Membership-Code zum Kopieren.