Zum Inhalt springen
UI Kit
Accent

Newsletter-Archiv

Jede Ausgabe, die verschickt wurde, lesbar im Web. Die neueste Ausgabe neben der Anmeldung, eine durchsuchbare Liste, gezeichnete Mailvorschauen, eine Ausgabe als Webseite und ein Kalender des Jahres.

Eine von 2 Components in Newsletter

5 Variants

Variants

Neueste Ausgabe

Die neueste Ausgabe groß mit pulsierender Marke, daneben die Anmeldung mit Lesenden und Rhythmus, die früheren Ausgaben in einer Reihe.

Im Studio öffnen

Durchsuchbare Liste

Jede Ausgabe mit Nummer, Vorschau und Datum. Die Suche markiert den Treffer, Jahre wechseln mit gleitender Pille.

Im Studio öffnen

Mailvorschauen

Ausgaben als gezeichnete Mailvorschauen mit ihrer Überschrift, sie heben und neigen sich beim Darüberfahren. Die Anmeldung steht neben der Überschrift.

Im Studio öffnen

Webversion

Eine Ausgabe als Webseite mit Mailkopf, einem Inhaltsverzeichnis, das der Leseposition folgt, der Anmeldung mit umlaufendem Licht und den benachbarten Ausgaben.

Im Studio öffnen

Kalender

Ein Jahr als zwölf Monate, jede Ausgabe ein Punkt, eine Linie zeigt die vollen Monate. Ein Monat öffnet seine Ausgaben daneben, Jahre wechseln darüber.

Im Studio öffnen

Details

Was Newsletter-Archiv mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Newsletter
Bereich
Marketing
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/newsletter-archive.tsx
Bringt mit
utilskitbuttoniconinputrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 28 eigene Texte

Props

Die Schnittstelle NewsletterArchiveProps aus components/ui/newsletter-archive.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    NewsletterArchiveVariant

    latestlistcoversreadercalendar

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • nameoptional
    string

    Name of the newsletter

  • readersoptional
    string

    Number of readers, shown next to the sign up

  • cadenceoptional
    string

    How often it comes, e.g. every second Thursday

  • issuesoptional
    NewsletterIssue[]
  • onSubscribeoptional
    (email: string) => void

    Called with the email address, the sign up shows its success state

  • backHrefoptional
    string

    reader: address of the archive, shows the link back

Eingebaute Texte

Diese Texte schreibt Newsletter-Archiv selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 28 Texte anzeigen
issueEnglisch Issue {n}Deutsch Ausgabe {n}
latestEnglisch Latest issueDeutsch Neueste Ausgabe
readEnglisch Read the issueDeutsch Ausgabe lesen
beforeEnglisch Earlier issuesDeutsch Frühere Ausgaben
allEnglisch AllDeutsch Alle
searchEnglisch Search the archiveDeutsch Im Archiv suchen
searchPhEnglisch Search issuesDeutsch Ausgaben durchsuchen
foundEnglisch {n} issuesDeutsch {n} Ausgaben
found1Englisch 1 issueDeutsch 1 Ausgabe
noneEnglisch No issue mentions “{q}” yet. Try a broader word.Deutsch Noch keine Ausgabe erwähnt „{q}“. Versuch ein allgemeineres Wort.
minEnglisch {n} minDeutsch {n} Min.
emailEnglisch Email addressDeutsch E-Mail-Adresse
emailPhEnglisch you@example.comDeutsch du@beispiel.de
subscribeEnglisch SubscribeDeutsch Abonnieren
subscribedEnglisch Almost there. Check your inbox to confirm.Deutsch Fast geschafft. Bestätige kurz in deinem Postfach.
readersEnglisch {n} readersDeutsch {n} Lesende
freeEnglisch Free, unsubscribe with one click.Deutsch Kostenlos, mit einem Klick abbestellbar.
contentsEnglisch In this issueDeutsch In dieser Ausgabe
backEnglisch All issuesDeutsch Alle Ausgaben
previousEnglisch Previous issueDeutsch Vorherige Ausgabe
nextEnglisch Next issueDeutsch Nächste Ausgabe
likedEnglisch Liked this issue?Deutsch Hat dir diese Ausgabe gefallen?
likedTextEnglisch Get the next one in your inbox.Deutsch Hol dir die nächste ins Postfach.
yearEnglisch YearDeutsch Jahr
noIssueEnglisch No issueDeutsch Keine Ausgabe
issuesInEnglisch Issues in {m}Deutsch Ausgaben im {m}
fromEnglisch FromDeutsch Von
toEnglisch ToDeutsch An

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 633 Zeilen
components/ui/newsletter-archive.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Stagger, useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';

/*
 * The archive of a newsletter: every issue that went out, readable on the
 * web, so the next reader can see what they sign up for.
 *
 *   latest     the newest issue large with the sign up next to it and the
 *              issues before it in a row
 *   list       every issue with search and year filter, the match marked
 *   covers     issues as drawn mail previews, the preview opens on hover
 *   reader     one issue as a web page: contents, sections, sign up at the end
 *   calendar   a year as twelve months, each issue a dot, the month opens its issues
 */

export type NewsletterArchiveVariant = 'latest' | 'list' | 'covers' | 'reader' | 'calendar';

export interface NewsletterIssue {
  number: number;
  title: string;
  /** One or two sentences, the preview line of the mail */
  summary?: string;
  /** ISO date, e.g. 2026-10-14 */
  date: string;
  /** Minutes */
  readingTime?: number;
  topics?: string[];
  href?: string;
  /** reader: the sections of the issue */
  sections?: { title: string; text: string[]; link?: { label: string; href: string } }[];
}

export interface NewsletterArchiveProps {
  variant?: NewsletterArchiveVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Name of the newsletter */
  name?: string;
  /** Number of readers, shown next to the sign up */
  readers?: string;
  /** How often it comes, e.g. every second Thursday */
  cadence?: string;
  issues?: NewsletterIssue[];
  /** Called with the email address, the sign up shows its success state */
  onSubscribe?: (email: string) => void;
  /** reader: address of the archive, shows the link back */
  backHref?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    issue: 'Issue {n}',
    latest: 'Latest issue',
    read: 'Read the issue',
    before: 'Earlier issues',
    all: 'All',
    search: 'Search the archive',
    searchPh: 'Search issues',
    found: '{n} issues',
    found1: '1 issue',
    none: 'No issue mentions “{q}” yet. Try a broader word.',
    min: '{n} min',
    email: 'Email address',
    emailPh: 'you@example.com',
    subscribe: 'Subscribe',
    subscribed: 'Almost there. Check your inbox to confirm.',
    readers: '{n} readers',
    free: 'Free, unsubscribe with one click.',
    contents: 'In this issue',
    back: 'All issues',
    previous: 'Previous issue',
    next: 'Next issue',
    liked: 'Liked this issue?',
    likedText: 'Get the next one in your inbox.',
    year: 'Year',
    noIssue: 'No issue',
    issuesIn: 'Issues in {m}',
    from: 'From',
    to: 'To',
  },
  de: {
    issue: 'Ausgabe {n}',
    latest: 'Neueste Ausgabe',
    read: 'Ausgabe lesen',
    before: 'Frühere Ausgaben',
    all: 'Alle',
    search: 'Im Archiv suchen',
    searchPh: 'Ausgaben durchsuchen',
    found: '{n} Ausgaben',
    found1: '1 Ausgabe',
    none: 'Noch keine Ausgabe erwähnt „{q}“. Versuch ein allgemeineres Wort.',
    min: '{n} Min.',
    email: 'E-Mail-Adresse',
    emailPh: 'du@beispiel.de',
    subscribe: 'Abonnieren',
    subscribed: 'Fast geschafft. Bestätige kurz in deinem Postfach.',
    readers: '{n} Lesende',
    free: 'Kostenlos, mit einem Klick abbestellbar.',
    contents: 'In dieser Ausgabe',
    back: 'Alle Ausgaben',
    previous: 'Vorherige Ausgabe',
    next: 'Nächste Ausgabe',
    liked: 'Hat dir diese Ausgabe gefallen?',
    likedText: 'Hol dir die nächste ins Postfach.',
    year: 'Jahr',
    noIssue: 'Keine Ausgabe',
    issuesIn: 'Ausgaben im {m}',
    from: 'Von',
    to: 'An',
  },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Shared pieces                                                       */
/* ------------------------------------------------------------------ */

function useDates() {
  const loc = useLocale() === 'de' ? 'de-DE' : 'en-US';
  const long = new Intl.DateTimeFormat(loc, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
  const short = new Intl.DateTimeFormat(loc, { day: 'numeric', month: 'short', timeZone: 'UTC' });
  const month = new Intl.DateTimeFormat(loc, { month: 'long', timeZone: 'UTC' });
  const monthShort = new Intl.DateTimeFormat(loc, { month: 'short', timeZone: 'UTC' });
  const d = (iso: string) => new Date(`${iso}T12:00:00Z`);
  return { long: (s: string) => long.format(d(s)), short: (s: string) => short.format(d(s)), month: (m: number) => month.format(new Date(Date.UTC(2026, m, 15))), monthShort: (m: number) => monthShort.format(new Date(Date.UTC(2026, m, 15))), year: (s: string) => d(s).getUTCFullYear(), monthOf: (s: string) => d(s).getUTCMonth() };
}

function Hit({ text, q }: { text: string; q: string }) {
  const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
  if (i < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] px-px text-foreground">{text.slice(i, i + q.length)}</mark>
      {text.slice(i + q.length)}
    </>
  );
}

/** Email field and button; turns into a confirmation after sending */
function SignUp({ t, onSubscribe, compact, className }: { t: Texts; onSubscribe?: (email: string) => void; compact?: boolean; className?: string }) {
  const uid = useId();
  const [done, setDone] = useState(false);
  const [email, setEmail] = useState('');
  if (done)
    return (
      <p role="status" className={cn('kit-rise flex items-start gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,transparent)] p-4 text-[14.5px] leading-[1.5] text-foreground', className)}>
        <span className="grid size-6 shrink-0 place-items-center rounded-full bg-success text-white">
          <Icon name="check" size={14} strokeWidth={2.4} />
        </span>
        {t.subscribed}
      </p>
    );
  return (
    <form
      className={cn('flex flex-col gap-2', !compact && 'sm:flex-row', className)}
      onSubmit={(e) => {
        e.preventDefault();
        if (!email.includes('@')) return;
        onSubscribe?.(email);
        setDone(true);
      }}
    >
      <label htmlFor={uid} className="sr-only">
        {t.email}
      </label>
      <Input id={uid} type="email" required autoComplete="email" placeholder={t.emailPh} value={email} onChange={(e) => setEmail(e.target.value)} className={cn('min-w-0', !compact && 'sm:flex-1')} />
      <Button type="submit" magnetic className={cn('max-sm:w-full', compact && 'w-full')}>
        {t.subscribe}
      </Button>
    </form>
  );
}

/** A drawn mail preview: masthead, headline lines, a picture block and text lines */
function MailPreview({ n, name, title, hot }: { n: number; name?: string; title: string; hot?: boolean }) {
  const seed = (n * 37) % 5;
  return (
    <div data-clip-ok className="relative flex aspect-[3/4] flex-col overflow-hidden rounded-xl border border-border bg-card p-[8%] shadow-[var(--highlight),var(--shadow-sm)] [container-type:inline-size]" aria-hidden>
      <div className="flex items-center justify-between">
        <span className="text-[6cqw] font-bold tracking-[-0.02em] text-foreground">{name}</span>
        <span className="text-[4.5cqw] tabular-nums text-muted-foreground">#{n}</span>
      </div>
      <span className="mt-[4cqw] h-px bg-border" />
      <p className="mt-[6cqw] line-clamp-3 text-[7.4cqw] font-semibold leading-[1.15] tracking-[-0.015em] text-foreground">{title}</p>
      <div className={cn('mt-[6cqw] h-[30cqw] rounded-[2cqw] transition-transform duration-700 ease-[var(--ease-kit)]', hot && 'scale-[1.03]')} style={{ background: `linear-gradient(${120 + seed * 30}deg, color-mix(in oklab, var(--primary) ${30 + seed * 8}%, var(--card)), color-mix(in oklab, var(--primary) 8%, var(--muted)))` }} />
      <div className="mt-[6cqw] space-y-[2.6cqw]">
        {[92, 100, 84, 96, 60].map((w, i) => (
          <span key={i} className="block h-[2.4cqw] rounded-full bg-muted" style={{ width: `${w - ((seed + i) % 3) * 6}%` }} />
        ))}
      </div>
      <span className="mt-[7cqw] h-[2.6cqw] w-[44%] rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,var(--muted))]" />
      <div className="mt-[3cqw] space-y-[2.6cqw]">
        {[88, 70].map((w, i) => (
          <span key={i} className="block h-[2.4cqw] rounded-full bg-muted" style={{ width: `${w}%` }} />
        ))}
      </div>
      <span className="mt-auto h-[9cqw] w-[42%] rounded-[2cqw] bg-primary" />
    </div>
  );
}

function Head({ eyebrow, title, description, aside }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; aside?: React.ReactNode }) {
  if (!title && !eyebrow) return null;
  return (
    <div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
      <div className="max-w-[42rem]">
        {eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
        {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
        {description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
      </div>
      {aside}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* latest                                                              */
/* ------------------------------------------------------------------ */

function Latest(p: NewsletterArchiveProps & { t: Texts }) {
  const { t } = p;
  const dates = useDates();
  const [first, ...rest] = p.issues ?? [];
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  if (!first) return null;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-16">
          <Reveal effect="rise">
            <a href={first.href ?? '#'} className="group/l block h-full rounded-[calc(var(--radius)+12px)] border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
              <p className="flex items-center gap-2 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                <span className="relative flex size-2">
                  <span className="absolute inset-0 rounded-full bg-primary opacity-60 motion-safe:animate-ping" />
                  <span className="relative size-2 rounded-full bg-primary" />
                </span>
                {t.latest}
                <span className="text-muted-foreground">· {t.issue.replace('{n}', String(first.number))}</span>
              </p>
              <h2 className="mt-5 text-balance text-[30px] font-semibold leading-[1.12] tracking-[-0.022em] text-foreground sm:text-[40px]">{first.title}</h2>
              {first.summary && <p className="mt-4 max-w-[36rem] text-pretty text-[17px] leading-[1.6] text-muted-foreground">{first.summary}</p>}
              <p className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 text-[14px] text-muted-foreground">
                <span className="tabular-nums">{dates.long(first.date)}</span>
                {first.readingTime && <span className="tabular-nums">· {t.min.replace('{n}', String(first.readingTime))}</span>}
                <span className="ml-auto inline-flex items-center gap-1.5 font-medium text-foreground">
                  {t.read}
                  <Icon name="arrow-right" size={16} className="transition-transform duration-300 group-hover/l:translate-x-1" />
                </span>
              </p>
            </a>
          </Reveal>
          <div className="flex flex-col justify-center">
            {p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
            {p.title && <h3 className="kit-rise text-balance text-[28px] font-semibold leading-[1.15] tracking-[-0.02em] text-foreground sm:text-[32px]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h3>}
            {p.description && <p className="kit-rise mt-3 text-pretty text-[16.5px] leading-[1.6] text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
            <SignUp t={t} onSubscribe={p.onSubscribe} className="kit-rise mt-6" />
            <p className="kit-rise mt-3 text-[13px] text-muted-foreground" style={{ '--kit-delay': '200ms' } as React.CSSProperties}>
              {[p.readers ? t.readers.replace('{n}', p.readers) : null, p.cadence, t.free].filter(Boolean).join(' · ')}
            </p>
          </div>
        </div>
        {rest.length > 0 && (
          <div className="mt-14">
            <div className="flex items-center justify-between gap-4 border-b border-border pb-3">
              <h3 className="text-[15px] font-semibold text-foreground">{t.before}</h3>
            </div>
            <div ref={rowRef} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 lg:mx-0 lg:px-0 [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
              <Stagger as="ol" step={60} className="flex w-max gap-px lg:grid lg:w-full lg:grid-cols-4">
                {rest.slice(0, 4).map((x) => (
                  <li key={x.number} className="w-[17rem] border-r border-border px-6 first:pl-0 last:border-r-0 lg:w-auto">
                    <a href={x.href ?? '#'} className="group/i flex h-full flex-col py-5">
                      <span className="text-[12.5px] tabular-nums text-muted-foreground">
                        #{x.number} · {dates.short(x.date)}
                      </span>
                      <span className="mt-2 text-pretty text-[16px] font-semibold leading-[1.35] text-foreground underline decoration-transparent underline-offset-4 transition-colors group-hover/i:decoration-current">{x.title}</span>
                    </a>
                  </li>
                ))}
              </Stagger>
            </div>
          </div>
        )}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* list                                                                */
/* ------------------------------------------------------------------ */

function List(p: NewsletterArchiveProps & { t: Texts }) {
  const { t } = p;
  const dates = useDates();
  const issues = p.issues ?? [];
  const years = useMemo(() => [...new Set(issues.map((x) => dates.year(x.date)))], [issues]); // eslint-disable-line react-hooks/exhaustive-deps
  const [year, setYear] = useState<number | null>(null);
  const [q, setQ] = useState('');
  const query = q.trim();
  const shown = issues.filter((x) => (year === null || dates.year(x.date) === year) && (!query || `${x.title} ${x.summary ?? ''} ${(x.topics ?? []).join(' ')}`.toLowerCase().includes(query.toLowerCase())));
  const seg = useRef<HTMLDivElement>(null);
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
  useLayoutEffect(() => {
    const b = seg.current?.querySelector<HTMLElement>('[aria-pressed="true"]');
    if (b) setPill({ x: b.offsetLeft, w: b.offsetWidth });
  }, [year, years.length]);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mx-auto max-w-[52rem]">
          <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
          <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
            <div className="min-w-0 flex-1">
              <Input type="search" icon="search" aria-label={t.search} placeholder={t.searchPh} value={q} onChange={(e) => setQ(e.target.value)} />
            </div>
            <div ref={seg} role="group" aria-label={t.year} className="relative inline-flex self-start rounded-xl border border-border bg-surface p-1">
              {pill && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
              {[null, ...years].map((y) => (
                <button key={String(y)} type="button" aria-pressed={year === y} onClick={() => setYear(y)} className={cn('relative min-h-10 rounded-lg px-3.5 text-[14px] font-medium tabular-nums transition-colors duration-300 max-sm:min-h-11', year === y ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
                  {y ?? t.all}
                </button>
              ))}
            </div>
          </div>
          <p className="mt-4 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
            {shown.length ? (shown.length === 1 ? t.found1 : t.found.replace('{n}', String(shown.length))) : t.none.replace('{q}', query)}
          </p>
          <ol key={`${year}`} className="mt-3 border-t border-border">
            {shown.map((x, i) => (
              <li key={x.number} className="kit-rise border-b border-border" style={{ '--kit-delay': `${Math.min(i, 8) * 40}ms`, animationDuration: '.6s' } as React.CSSProperties}>
                <a href={x.href ?? '#'} className="group/i -mx-3 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-x-4 rounded-xl px-3 py-5 transition-colors duration-300 hover:bg-surface sm:grid-cols-[4rem_minmax(0,1fr)_7.5rem]">
                  <span className="pt-0.5 text-[15px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">#{x.number}</span>
                  <span className="min-w-0">
                    <span className="block text-pretty text-[17px] font-semibold leading-[1.35] text-foreground">
                      <Hit text={x.title} q={query} />
                    </span>
                    {x.summary && (
                      <span className="mt-1 block text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">
                        <Hit text={x.summary} q={query} />
                      </span>
                    )}
                    <span className="mt-1.5 block text-[13px] tabular-nums text-muted-foreground sm:hidden">{dates.long(x.date)}</span>
                  </span>
                  <span className="hidden text-right text-[13.5px] tabular-nums text-muted-foreground sm:block">
                    {dates.long(x.date)}
                    {x.readingTime && <span className="mt-0.5 block">{t.min.replace('{n}', String(x.readingTime))}</span>}
                  </span>
                </a>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* covers                                                              */
/* ------------------------------------------------------------------ */

function Covers(p: NewsletterArchiveProps & { t: Texts }) {
  const { t } = p;
  const dates = useDates();
  const issues = p.issues ?? [];
  const [hot, setHot] = useState<number | null>(null);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} aside={<SignUp t={t} onSubscribe={p.onSubscribe} className="w-full lg:w-[26rem]" />} />
        <Stagger as="ul" step={60} className="grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-4 lg:gap-x-6">
          {issues.slice(0, 8).map((x) => (
            <li key={x.number}>
              <a href={x.href ?? '#'} onPointerEnter={() => setHot(x.number)} onPointerLeave={() => setHot(null)} onFocus={() => setHot(x.number)} onBlur={() => setHot(null)} className="group/c block">
                <div className="transition-transform duration-500 ease-[var(--ease-kit)] group-hover/c:-translate-y-1.5 group-hover/c:rotate-[-0.6deg]">
                  <MailPreview n={x.number} name={p.name} title={x.title} hot={hot === x.number} />
                </div>
                <p className="mt-3 text-[12.5px] tabular-nums text-muted-foreground">
                  #{x.number} · {dates.short(x.date)}
                </p>
                <p className="mt-1 line-clamp-2 text-pretty text-[15px] font-semibold leading-[1.35] text-foreground">{x.title}</p>
              </a>
            </li>
          ))}
        </Stagger>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* reader                                                              */
/* ------------------------------------------------------------------ */

function Reader(p: NewsletterArchiveProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const dates = useDates();
  const issues = p.issues ?? [];
  const [i, setI] = useState(0);
  const x = issues[i];
  const [active, setActive] = useState(0);
  const body = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let frame = 0;
    const update = () => {
      frame = 0;
      const els = [...(body.current?.querySelectorAll<HTMLElement>('[data-sec]') ?? [])];
      let cur = 0;
      els.forEach((el, k) => {
        if (el.getBoundingClientRect().top <= window.innerHeight * 0.35) cur = k;
      });
      // The last section can never reach the line at the end of the page
      const last = els[els.length - 1];
      if (last && window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 && last.getBoundingClientRect().top < window.innerHeight) cur = els.length - 1;
      setActive(cur);
    };
    const on = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', on, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', on);
    };
  }, [i]);
  if (!x) return null;
  const sections = x.sections ?? [];
  return (
    <article className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="lg:grid lg:grid-cols-[13rem_minmax(0,40rem)] lg:justify-center lg:gap-16">
          <aside className="hidden lg:block">
            <div className="sticky top-10">
              {p.backHref && (
                <a href={p.backHref} className="inline-flex min-h-11 items-center gap-2 text-[14px] font-medium text-muted-foreground transition-colors hover:text-foreground">
                  <Icon name="arrow-left" size={15} />
                  {t.back}
                </a>
              )}
              <p className="mt-8 text-[13px] font-semibold text-foreground">{t.contents}</p>
              <ol className="mt-3 space-y-0.5 border-l border-border">
                {sections.map((s, k) => (
                  <li key={k}>
                    <a href={`#${uid}-${k}`} aria-current={active === k ? 'location' : undefined} className={cn('-ml-px flex min-h-9 items-center border-l py-1.5 pl-4 text-[14px] leading-snug transition-colors duration-300', active === k ? 'border-primary font-medium text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground')}>
                      {s.title}
                    </a>
                  </li>
                ))}
              </ol>
            </div>
          </aside>
          <div key={x.number} className="min-w-0">
            {p.backHref && (
              <a href={p.backHref} className="mb-4 inline-flex min-h-11 items-center gap-2 text-[14px] font-medium text-muted-foreground transition-colors hover:text-foreground lg:hidden">
                <Icon name="arrow-left" size={15} />
                {t.back}
              </a>
            )}
            {/* The mail header, as it arrived */}
            <div className="kit-rise rounded-xl border border-border bg-surface px-4 py-3 text-[13.5px] text-muted-foreground">
              <p>
                <span className="inline-block w-12 text-subtle-foreground">{t.from}</span>
                <span className="text-foreground">{p.name}</span>
              </p>
              <p className="mt-1">
                <span className="inline-block w-12 text-subtle-foreground">{t.to}</span>
                {t.readers.replace('{n}', p.readers ?? '')}
              </p>
            </div>
            <p className="kit-rise mt-8 text-[13px] font-medium tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
              {t.issue.replace('{n}', String(x.number))} · {dates.long(x.date)}
            </p>
            <h2 className="kit-rise mt-3 text-balance text-h1 font-semibold text-foreground" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
              {x.title}
            </h2>
            {x.summary && <p className="kit-rise mt-5 text-pretty text-[19px] leading-[1.55] text-muted-foreground" style={{ '--kit-delay': '140ms' } as React.CSSProperties}>{x.summary}</p>}
            <div ref={body} className="mt-10 border-t border-border">
              {sections.map((s, k) => (
                <section key={k} id={`${uid}-${k}`} data-sec className="scroll-mt-10 border-b border-border py-10">
                  <Reveal effect="fade">
                    <p className="text-[12.5px] font-semibold tabular-nums text-muted-foreground">{String(k + 1).padStart(2, '0')}</p>
                    <h3 className="mt-2 text-balance text-[24px] font-semibold leading-[1.25] tracking-[-0.014em] text-foreground">{s.title}</h3>
                    {s.text.map((para, j) => (
                      <p key={j} className="mt-4 text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_88%,var(--background))]">
                        {para}
                      </p>
                    ))}
                    {s.link && (
                      <a href={s.link.href} className="group/a mt-4 inline-flex min-h-11 items-center gap-1.5 text-[15px] font-medium text-primary">
                        {s.link.label}
                        <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/a:translate-x-0.5" />
                      </a>
                    )}
                  </Reveal>
                </section>
              ))}
            </div>
            <Reveal effect="rise" className="kit-card relative mt-12 overflow-hidden p-6 sm:p-8">
              <span className="kit-beam pointer-events-none absolute inset-0 rounded-[inherit]" aria-hidden />
              <p className="text-[20px] font-semibold text-foreground">{t.liked}</p>
              <p className="mt-1 text-[15px] text-muted-foreground">{t.likedText}</p>
              <SignUp t={t} onSubscribe={p.onSubscribe} className="mt-5" />
            </Reveal>
            <nav aria-label={t.back} className="mt-8 grid grid-cols-2 gap-3">
              {[issues[i + 1], issues[i - 1]].map((n, k) => (
                <button key={k} type="button" disabled={!n} onClick={() => n && setI(i + (k ? -1 : 1))} className={cn('flex min-h-20 flex-col justify-center gap-1 rounded-xl border border-border px-4 py-3 text-left transition-[background-color,border-color] hover:border-border-strong hover:bg-surface disabled:pointer-events-none disabled:opacity-0', k && 'items-end text-right')}>
                  <span className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
                    {!k && <Icon name="arrow-left" size={13} />}
                    {k ? t.next : t.previous}
                    {k ? <Icon name="arrow-right" size={13} /> : null}
                  </span>
                  <span className="line-clamp-2 text-[14.5px] font-semibold text-foreground">{n?.title}</span>
                </button>
              ))}
            </nav>
          </div>
        </div>
      </div>
    </article>
  );
}

/* ------------------------------------------------------------------ */
/* calendar                                                            */
/* ------------------------------------------------------------------ */

function Calendar(p: NewsletterArchiveProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const dates = useDates();
  const issues = p.issues ?? [];
  const years = useMemo(() => [...new Set(issues.map((x) => dates.year(x.date)))].sort((a, b) => b - a), [issues]); // eslint-disable-line react-hooks/exhaustive-deps
  const [year, setYear] = useState(years[0]);
  const inYear = issues.filter((x) => dates.year(x.date) === year);
  const byMonth = Array.from({ length: 12 }, (_, m) => inYear.filter((x) => dates.monthOf(x.date) === m));
  const latestMonth = byMonth.reduce((a, l, m) => (l.length ? m : a), 0);
  const [open, setOpen] = useState<number>(latestMonth);
  useEffect(() => setOpen(latestMonth), [year]); // eslint-disable-line react-hooks/exhaustive-deps
  const max = Math.max(1, ...byMonth.map((l) => l.length));
  const sel = byMonth[open] ?? [];
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head
          eyebrow={p.eyebrow}
          title={p.title}
          description={p.description}
          aside={
            <div role="group" aria-label={t.year} className="inline-flex self-start rounded-xl border border-border bg-surface p-1 lg:self-end">
              {years.map((y) => (
                <button key={y} type="button" aria-pressed={year === y} onClick={() => setYear(y)} className={cn('min-h-10 rounded-lg px-3.5 text-[14px] font-medium tabular-nums transition-[background-color,color] duration-300 max-sm:min-h-11', year === y ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
                  {y}
                </button>
              ))}
            </div>
          }
        />
        <div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] lg:gap-14">
          <div role="tablist" aria-label={String(year)} className="grid grid-cols-4 content-start gap-2 self-start sm:grid-cols-6">
            {byMonth.map((list, m) => {
              const has = list.length > 0;
              return (
                <button
                  key={m}
                  type="button"
                  role="tab"
                  id={`${uid}-m${m}`}
                  aria-selected={open === m}
                  aria-controls={`${uid}-panel`}
                  disabled={!has}
                  onClick={() => setOpen(m)}
                  className={cn('group/m relative flex aspect-square flex-col justify-between rounded-xl border p-2.5 text-left transition-[background-color,border-color,transform] duration-300 sm:p-3', open === m ? 'border-transparent bg-primary text-primary-foreground shadow-[var(--shadow-md)]' : has ? 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong' : 'border-dashed border-border text-subtle-foreground')}
                >
                  <span className={cn('text-[13px] font-medium', open === m ? '' : has ? 'text-foreground' : '')}>{dates.monthShort(m)}</span>
                  <span className="flex flex-wrap gap-1" aria-hidden>
                    {list.map((x) => (
                      <span key={x.number} className={cn('size-2 rounded-full', open === m ? 'bg-primary-foreground' : 'bg-primary')} />
                    ))}
                  </span>
                  <span className="sr-only">{has ? (list.length === 1 ? t.found1 : t.found.replace('{n}', String(list.length))) : t.noIssue}</span>
                  {has && <span aria-hidden className={cn('absolute bottom-0 left-2.5 right-2.5 h-0.5 origin-left rounded-full', open === m ? 'bg-primary-foreground/40' : 'bg-[color-mix(in_oklab,var(--primary)_40%,transparent)]')} style={{ transform: `scaleX(${round(list.length / max, 3)})` }} />}
                </button>
              );
            })}
          </div>
          <div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-m${open}`}>
            <p className="text-[13px] font-semibold text-foreground">{t.issuesIn.replace('{m}', `${dates.month(open)} ${year}`)}</p>
            <ol key={`${year}-${open}`} className="mt-3 divide-y divide-border border-y border-border">
              {sel.map((x, k) => (
                <li key={x.number} className="kit-rise" style={{ '--kit-delay': `${k * 60}ms`, animationDuration: '.6s' } as React.CSSProperties}>
                  <a href={x.href ?? '#'} className="group/i block py-4">
                    <span className="text-[12.5px] tabular-nums text-muted-foreground">
                      #{x.number} · {dates.long(x.date)}
                    </span>
                    <span className="mt-1 block text-pretty text-[17px] font-semibold leading-[1.35] text-foreground underline decoration-transparent underline-offset-4 transition-colors group-hover/i:decoration-current">{x.title}</span>
                    {x.summary && <span className="mt-1 block line-clamp-2 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{x.summary}</span>}
                  </a>
                </li>
              ))}
            </ol>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

export function NewsletterArchive(props: NewsletterArchiveProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'latest' } = props;
  if (variant === 'list') return <List {...props} t={t} />;
  if (variant === 'covers') return <Covers {...props} t={t} />;
  if (variant === 'reader') return <Reader {...props} t={t} />;
  if (variant === 'calendar') return <Calendar {...props} t={t} />;
  return <Latest {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Newsletter-Archiv?

Mit aiw add @ai-website/newsletter-archive. Der Befehl legt die Datei components/ui/newsletter-archive.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Newsletter-Archiv?

5 Varianten, Neueste Ausgabe, Durchsuchbare Liste, Mailvorschauen, Webversion und Kalender. Du wählst sie mit der Prop variant, zum Beispiel variant="list".

Kann ich die Texte von Newsletter-Archiv übersetzen?

Ja. Newsletter-Archiv bringt 28 eigene Texte auf Deutsch und Englisch mit, etwa „Neueste Ausgabe“, „Ausgabe lesen“ und „Frühere Ausgaben“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Newsletter-Archiv einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Newsletter-Archiv mit aiw_components und installieren sie mit aiw_add @ai-website/newsletter-archive. Nenn im Prompt die Variante, etwa variant="latest", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Newsletter-Archiv ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden NewsletterArchive-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert