Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Stufen

Stufen als Leiter, jede baut auf der vorigen auf. Monatlich oder jährlich hinter einem gleitenden Schalter, der Monatspreis gleitet, die Hauptstufe hat ein Licht an der Kante.

Im Studio öffnen

Bibliothek

Alles in der Bibliothek, gesperrte Inhalte unscharf, aber lesbar. Eine Stufe wählen und sehen, was sich öffnet, das Schloss dreht sich, der Balken zählt mit.

Im Studio öffnen

Blick hinein

Der Mitgliederbereich, wie er drinnen aussieht. Die neuesten Beiträge steigen ein, gesperrte enden mitten im Satz, eine Live-Zahl der Mitglieder, die günstigste Stufe als Einstieg.

Im Studio öffnen

Monatspakete

Jeder Monat bringt ein Thema. Eine Linie durchs Jahr füllt sich beim Scrollen, der aktuelle Monat leuchtet, vergangene lassen sich öffnen, kommende zeigen, was geplant ist. Auf dem Telefon eine Wischreihe.

Im Studio öffnen

Mitgliedschaft verschenken

Dauer und Stufe hinter gleitenden Schaltern, die Karte ändert sich beim Tippen und neigt sich zum Zeiger, der Preis gleitet.

Im Studio öffnen

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
Bringt mit
utilskitbuttoniconmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 39 eigene Texte
In Sammlungen
Creator-Seite

Props

Die Schnittstelle MembershipProps aus components/ui/membership.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MembershipVariant

    tierslibraryinsidedropsgift

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • tiersoptional
    MemberTier[]
  • itemsoptional
    MemberItem[]
  • postsoptional
    MemberPost[]
  • membersoptional
    number

    inside: members right now

  • hostoptional
    { name: string; photo?: React.ReactNode }
  • dropsoptional
    MemberDrop[]
  • currentoptional
    string

    drops: today as YYYY-MM, otherwise the browser month after loading

  • brandoptional
    string

    gift: name on the card

  • currencyoptional
    string
  • onSubmitoptional
    (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
billingEnglisch BillingDeutsch Abrechnung
monthlyEnglisch MonthlyDeutsch Monatlich
yearlyEnglisch YearlyDeutsch Jährlich
saveEnglisch 2 months freeDeutsch 2 Monate gratis
perMonthEnglisch / monthDeutsch / Monat
billedYearlyEnglisch {p} billed once a yearDeutsch {p} einmal im Jahr
joinEnglisch Choose {name}Deutsch {name} wählen
popularEnglisch Most chosenDeutsch Meistgewählt
everythingEnglisch Everything in {name}, plusDeutsch Alles aus {name}, dazu
showForEnglisch Show what opens withDeutsch Zeigen, was sich öffnet mit
freeEnglisch FreeDeutsch Kostenlos
openEnglisch OpenDeutsch Offen
lockedEnglisch With {name}Deutsch Mit {name}
opensEnglisch {n} of {total} openDeutsch {n} von {total} offen
unlockEnglisch Unlock with {name}Deutsch Mit {name} freischalten
membersEnglisch {n} members insideDeutsch {n} Mitglieder sind dabei
readOnEnglisch Become a member to read onDeutsch Werde Mitglied, um weiterzulesen
latestEnglisch Latest in the clubDeutsch Neu im Club
newEnglisch NewDeutsch Neu
nowEnglisch NowDeutsch Jetzt
soonEnglisch ComingDeutsch Demnächst
pastEnglisch Open the dropDeutsch Paket öffnen
lengthEnglisch LengthDeutsch Dauer
monthsEnglisch {n} monthsDeutsch {n} Monate
tierEnglisch TierDeutsch Stufe
toEnglisch ForDeutsch Für
toPhEnglisch Name of the lucky oneDeutsch Name der beschenkten Person
messageEnglisch A few wordsDeutsch Ein paar Worte
messagePhEnglisch For the evenings you want to learn something new.Deutsch Für die Abende, an denen du etwas Neues lernen willst.
emailEnglisch Their emailDeutsch E-Mail der beschenkten Person
emailPhEnglisch friend@example.comDeutsch freundin@beispiel.de
whenEnglisch Send onDeutsch Senden am
buyEnglisch Give {length} for {price}Deutsch {length} für {price} verschenken
sentEnglisch 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.
againEnglisch Give another membershipDeutsch Weitere Mitgliedschaft verschenken
requiredEnglisch Please fill in this field.Deutsch Bitte fülle dieses Feld aus.
invalidEnglisch Please enter an email address like name@example.com.Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
giftEnglisch Gift membershipDeutsch Geschenk-Mitgliedschaft
validForEnglisch {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
components/ui/membership.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { 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">&nbsp;</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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert