Skip to content
UI Kit
Accent color

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

5 variants

Variants

Tiers

Tiers as a ladder, each one adds to the one before. Monthly or yearly behind a gliding switch, the price per month glides, the main tier has a light round its edge.

Open in Studio

Library

Everything in the library, locked items blurred but readable. Pick a tier and watch what opens, the lock turns, the bar counts how much is inside.

Open in Studio

A look inside

The member area as it looks inside. The latest posts rise in, locked ones fade out mid sentence, a live count of members, the cheapest tier as the way in.

Open in Studio

Monthly drops

Each month brings a theme. A line through the year fills while scrolling, the current month is lit, past ones open, coming ones show what is planned. A swipe row on phones.

Open in Studio

Gift membership

Length and tier behind gliding switches, the card updates while typing and tilts toward the pointer, the price glides.

Open in Studio

Details

What Membership brings along, read from the registry and the source.

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

Props

The MembershipProps interface from components/ui/membership.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 39 texts
billingEnglish BillingGerman Abrechnung
monthlyEnglish MonthlyGerman Monatlich
yearlyEnglish YearlyGerman Jährlich
saveEnglish 2 months freeGerman 2 Monate gratis
perMonthEnglish / monthGerman / Monat
billedYearlyEnglish {p} billed once a yearGerman {p} einmal im Jahr
joinEnglish Choose {name}German {name} wählen
popularEnglish Most chosenGerman Meistgewählt
everythingEnglish Everything in {name}, plusGerman Alles aus {name}, dazu
showForEnglish Show what opens withGerman Zeigen, was sich öffnet mit
freeEnglish FreeGerman Kostenlos
openEnglish OpenGerman Offen
lockedEnglish With {name}German Mit {name}
opensEnglish {n} of {total} openGerman {n} von {total} offen
unlockEnglish Unlock with {name}German Mit {name} freischalten
membersEnglish {n} members insideGerman {n} Mitglieder sind dabei
readOnEnglish Become a member to read onGerman Werde Mitglied, um weiterzulesen
latestEnglish Latest in the clubGerman Neu im Club
newEnglish NewGerman Neu
nowEnglish NowGerman Jetzt
soonEnglish ComingGerman Demnächst
pastEnglish Open the dropGerman Paket öffnen
lengthEnglish LengthGerman Dauer
monthsEnglish {n} monthsGerman {n} Monate
tierEnglish TierGerman Stufe
toEnglish ForGerman Für
toPhEnglish Name of the lucky oneGerman Name der beschenkten Person
messageEnglish A few wordsGerman Ein paar Worte
messagePhEnglish For the evenings you want to learn something new.German Für die Abende, an denen du etwas Neues lernen willst.
emailEnglish Their emailGerman E-Mail der beschenkten Person
emailPhEnglish friend@example.comGerman freundin@beispiel.de
whenEnglish Send onGerman Senden am
buyEnglish Give {length} for {price}German {length} für {price} verschenken
sentEnglish 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.
againEnglish Give another membershipGerman Weitere Mitgliedschaft verschenken
requiredEnglish Please fill in this field.German Bitte fülle dieses Feld aus.
invalidEnglish Please enter an email address like name@example.com.German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
giftEnglish Gift membershipGerman Geschenk-Mitgliedschaft
validForEnglish {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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard