Skip to content
UI Kit
Accent color

Newsletter archive

Every issue that went out, readable on the web. The latest issue next to the sign up, a searchable list, drawn mail previews, one issue as a web page and a calendar of the year.

One of 2 components in Newsletter

5 variants

Variants

Latest issue

The newest issue large with a pulsing marker, the sign up beside it with readers and cadence, the issues before it in a row.

Open in Studio

Searchable list

Every issue with number, summary and date. The search marks the match, years switch with a gliding pill.

Open in Studio

Mail previews

Issues as drawn mail previews with their headline, they lift and tilt a little on hover. The sign up sits next to the heading.

Open in Studio

Web version

One issue as a web page with the mail header, contents that follow the reading position, the sign up with a circling light and the issues next to it.

Open in Studio

Calendar

A year as twelve months, each issue a dot, a line shows the busy months. A month opens its issues beside it, years switch above.

Open in Studio

Details

What Newsletter archive brings along, read from the registry and the source.

Category
Newsletter
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/newsletter-archive.tsx
Brings along
utilskitbuttoniconinputrevealmotionsection
npm packages
None
Languages
English and German, 28 texts of its own

Props

The NewsletterArchiveProps interface from components/ui/newsletter-archive.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 28 texts
issueEnglish Issue {n}German Ausgabe {n}
latestEnglish Latest issueGerman Neueste Ausgabe
readEnglish Read the issueGerman Ausgabe lesen
beforeEnglish Earlier issuesGerman Frühere Ausgaben
allEnglish AllGerman Alle
searchEnglish Search the archiveGerman Im Archiv suchen
searchPhEnglish Search issuesGerman Ausgaben durchsuchen
foundEnglish {n} issuesGerman {n} Ausgaben
found1English 1 issueGerman 1 Ausgabe
noneEnglish No issue mentions “{q}” yet. Try a broader word.German Noch keine Ausgabe erwähnt „{q}“. Versuch ein allgemeineres Wort.
minEnglish {n} minGerman {n} Min.
emailEnglish Email addressGerman E-Mail-Adresse
emailPhEnglish you@example.comGerman du@beispiel.de
subscribeEnglish SubscribeGerman Abonnieren
subscribedEnglish Almost there. Check your inbox to confirm.German Fast geschafft. Bestätige kurz in deinem Postfach.
readersEnglish {n} readersGerman {n} Lesende
freeEnglish Free, unsubscribe with one click.German Kostenlos, mit einem Klick abbestellbar.
contentsEnglish In this issueGerman In dieser Ausgabe
backEnglish All issuesGerman Alle Ausgaben
previousEnglish Previous issueGerman Vorherige Ausgabe
nextEnglish Next issueGerman Nächste Ausgabe
likedEnglish Liked this issue?German Hat dir diese Ausgabe gefallen?
likedTextEnglish Get the next one in your inbox.German Hol dir die nächste ins Postfach.
yearEnglish YearGerman Jahr
noIssueEnglish No issueGerman Keine Ausgabe
issuesInEnglish Issues in {m}German Ausgaben im {m}
fromEnglish FromGerman Von
toEnglish ToGerman An

Accessibility

Read from the source of the component.

  • Sets aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles group, status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 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, 633 lines
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} />;
}

Frequent questions

How do I install Newsletter archive?

With aiw add @ai-website/newsletter-archive. The command puts components/ui/newsletter-archive.tsx into your project, the code is yours from then on. It brings along button, icon, input, reveal, motion and section. It needs no further npm packages.

Which variants does Newsletter archive have?

5 variants, Latest issue, Searchable list, Mail previews, Web version and Calendar. You pick one with the variant prop, for example variant="list".

Can I translate the texts of Newsletter archive?

Yes. Newsletter archive carries 28 texts of its own in English and German, such as “Latest issue”, “Read the issue” and “Earlier issues”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Newsletter archive?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Newsletter archive with aiw_components and install it with aiw_add @ai-website/newsletter-archive. Name the variant in your prompt, for example variant="latest", and the assistant writes your content straight into the props.

Can I adjust Newsletter archive without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching NewsletterArchive code to copy.

Often searched

Jump to

Copied to the clipboard