Zum Inhalt springen
UI Kit
Accent

Titelseite

Die Titelseite eines Magazins oder einer Nachrichtenseite, sortiert wie von einer Redaktion. Aufmacher, Ressorts mit mitlaufender Leiste, eine Printausgabe mit Inhalt, ein Morgenüberblick zum Abhaken und das Meistgelesene nach Zeitraum.

Eine von 3 Components in Redaktion

5 Variants

Variants

Aufmacher

Ein Aufmacher mit großem Bild, das beim Scrollen gleitet, drei Nebengeschichten und eine Reihe kurzer.

Im Studio öffnen

Ressorts

Die Titelseite nach Ressorts. Die Ressortleiste bleibt stehen, ihre Linie gleitet zum Ressort, das gerade gelesen wird, der Aufmacher wechselt von Ressort zu Ressort die Seite.

Im Studio öffnen

Printausgabe

Das Cover mit Titelschriftzug und Titelzeilen neben dem Inhaltsverzeichnis. Eine Geschichte im Inhalt lässt ihre Zeile auf dem Cover aufleuchten.

Im Studio öffnen

Morgenüberblick

Eine Handvoll Geschichten zum Abhaken, ein Ring zählt das Gelesene. Jede Geschichte öffnet ein kurzes Warum es zählt.

Im Studio öffnen

Meistgelesen

Platzierungen nach Zeitraum mit gleitendem Umschalter. Unter jeder Geschichte füllt sich ein Balken bis zu ihren Aufrufen, sobald die Liste hereinscrollt.

Im Studio öffnen

Details

Was Titelseite mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Redaktion
Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/magazine-front.tsx
Bringt mit
utilskitbuttoniconrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 17 eigene Texte
In Sammlungen
Blog oder Magazin

Props

Die Schnittstelle MagazineFrontProps aus components/ui/magazine-front.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MagazineFrontVariant

    cover-storysectionsissuedigestmost-read

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

    Name of the publication, shown as the masthead (issue, digest)

  • dateoptional
    string

    Today, the issue date or the edition, as shown

  • storiesoptional
    MagazineStory[]
  • sectionsoptional
    MagazineSection[]

    sections

  • periodsoptional
    MagazinePeriod[]

    most-read

  • issueoptional
    string

    issue: number of the issue and its price line, e.g. No. 42

  • coveroptional
    React.ReactNode

    issue: the cover picture

  • primaryActionoptional
    { label: string; href: string }

    issue: main action, e.g. read the issue

  • secondaryActionoptional
    { label: string; href: string }

Eingebaute Texte

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

Alle 17 Texte anzeigen
minutesEnglisch {n} minDeutsch {n} Min.
byEnglisch By {name}Deutsch Von {name}
allEnglisch All in {name}Deutsch Alles aus {name}
sectionsEnglisch SectionsDeutsch Ressorts
contentsEnglisch In this issueDeutsch In dieser Ausgabe
pageEnglisch p. {n}Deutsch S. {n}
pageLongEnglisch Page {n}Deutsch Seite {n}
markReadEnglisch Mark as readDeutsch Als gelesen markieren
unreadEnglisch Mark as unreadDeutsch Als ungelesen markieren
whyEnglisch Why it mattersDeutsch Warum es zählt
progressEnglisch {a} of {b} readDeutsch {a} von {b} gelesen
doneEnglisch You are up to date.Deutsch Du bist auf dem neuesten Stand.
readsEnglisch {n} readsDeutsch {n} Aufrufe
rankEnglisch Rank {n}Deutsch Platz {n}
periodEnglisch PeriodDeutsch Zeitraum
moreEnglisch More storiesDeutsch Weitere Geschichten
onCoverEnglisch On the coverDeutsch Auf dem Titel

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Titelseite reagiert selbst auf die Tasten Pfeil links und Pfeil rechts.
  • Setzt aria-checked, aria-controls, aria-current, aria-expanded, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio und radiogroup.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • 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).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 670 Zeilen
components/ui/magazine-front.tsx
'use client';

import { useEffect, useId, useLayoutEffect, 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 { Magnetic, Parallax, Stagger, useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';

/*
 * The front of a magazine or a news site: what is important today, sorted
 * the way an editor would sort it. Stories are plain data, pictures come in
 * as nodes, so the front works with any CMS.
 *
 *   cover-story  a lead story with a large picture, three side stories and
 *                a row of short ones
 *   sections     the front by section, a section bar that follows the
 *                reading position, the lead alternates sides
 *   issue        a print issue: the cover with its cover lines and the
 *                contents; a story in the contents lights up its line on the cover
 *   digest       the morning briefing, a handful of stories to tick off,
 *                each opens a short why it matters
 *   most-read    the most read stories by period, ranks with a bar of
 *                their reads that fills while scrolling
 */

export type MagazineFrontVariant = 'cover-story' | 'sections' | 'issue' | 'digest' | 'most-read';

export interface MagazineStory {
  title: string;
  /** One or two sentences under the title */
  dek?: string;
  /** Section label, e.g. City */
  section?: string;
  author?: string;
  /** As shown, e.g. 2 h ago or Oct 21 */
  date?: string;
  /** Minutes */
  readingTime?: number;
  href?: string;
  media?: React.ReactNode;
  /** issue: page in the print issue */
  page?: number;
  /** issue: also printed on the cover */
  onCover?: boolean;
  /** digest: the short why it matters, shown on open */
  why?: string;
  /** most-read: reads in the period */
  reads?: number;
}

export interface MagazineSection {
  name: string;
  href?: string;
  stories: MagazineStory[];
}

export interface MagazinePeriod {
  label: string;
  stories: MagazineStory[];
}

export interface MagazineFrontProps {
  variant?: MagazineFrontVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Name of the publication, shown as the masthead (issue, digest) */
  masthead?: string;
  /** Today, the issue date or the edition, as shown */
  date?: string;
  stories?: MagazineStory[];
  /** sections */
  sections?: MagazineSection[];
  /** most-read */
  periods?: MagazinePeriod[];
  /** issue: number of the issue and its price line, e.g. No. 42 */
  issue?: string;
  /** issue: the cover picture */
  cover?: React.ReactNode;
  /** issue: main action, e.g. read the issue */
  primaryAction?: { label: string; href: string };
  secondaryAction?: { label: string; href: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    minutes: '{n} min',
    by: 'By {name}',
    all: 'All in {name}',
    sections: 'Sections',
    contents: 'In this issue',
    page: 'p. {n}',
    pageLong: 'Page {n}',
    markRead: 'Mark as read',
    unread: 'Mark as unread',
    why: 'Why it matters',
    progress: '{a} of {b} read',
    done: 'You are up to date.',
    reads: '{n} reads',
    rank: 'Rank {n}',
    period: 'Period',
    more: 'More stories',
    onCover: 'On the cover',
  },
  de: {
    minutes: '{n} Min.',
    by: 'Von {name}',
    all: 'Alles aus {name}',
    sections: 'Ressorts',
    contents: 'In dieser Ausgabe',
    page: 'S. {n}',
    pageLong: 'Seite {n}',
    markRead: 'Als gelesen markieren',
    unread: 'Als ungelesen markieren',
    why: 'Warum es zählt',
    progress: '{a} von {b} gelesen',
    done: 'Du bist auf dem neuesten Stand.',
    reads: '{n} Aufrufe',
    rank: 'Platz {n}',
    period: 'Zeitraum',
    more: 'Weitere Geschichten',
    onCover: 'Auf dem Titel',
  },
};

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

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

function Meta({ s, t, className, withAuthor }: { s: MagazineStory; t: Texts; className?: string; withAuthor?: boolean }) {
  const bits = [withAuthor && s.author ? t.by.replace('{name}', s.author) : null, s.date, s.readingTime ? t.minutes.replace('{n}', String(s.readingTime)) : null].filter(Boolean);
  if (!bits.length) return null;
  return (
    <p className={cn('flex flex-wrap items-center gap-x-1.5 text-[13px] text-muted-foreground', className)}>
      {bits.map((b, i) => (
        <span key={i} className="inline-flex items-center gap-1.5 tabular-nums">
          {i > 0 && <span aria-hidden>·</span>}
          {b}
        </span>
      ))}
    </p>
  );
}

/** Section label in the accent, the one place where color leads the eye */
function Kicker({ children, className }: { children?: React.ReactNode; className?: string }) {
  if (!children) return null;
  return <p className={cn('text-[12.5px] font-semibold uppercase tracking-[0.07em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', className)}>{children}</p>;
}

/** The whole card is the link, the title underlines on hover */
function StoryLink({ s, className, children }: { s: MagazineStory; className?: string; children: React.ReactNode }) {
  return (
    <a href={s.href ?? '#'} className={cn('group/story block rounded-xl outline-offset-4', className)}>
      {children}
    </a>
  );
}

const TITLE_HOVER = 'underline decoration-transparent decoration-1 underline-offset-[0.18em] transition-[text-decoration-color] duration-300 group-hover/story:decoration-current';

function Picture({ media, aspect = '3 / 2', className, parallax }: { media?: React.ReactNode; aspect?: string; className?: string; parallax?: boolean }) {
  if (!media) return null;
  return (
    <div data-clip-ok className={cn('relative overflow-hidden rounded-xl bg-muted', className)} style={{ aspectRatio: aspect }}>
      <div className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/story:scale-[1.035] motion-reduce:transition-none [&>*]:size-full">
        {parallax ? (
          <Parallax speed={40} className="size-full scale-[1.1] [&>*]:size-full">
            {media}
          </Parallax>
        ) : (
          media
        )}
      </div>
    </div>
  );
}

function Head({ eyebrow, title, description, aside, center }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; aside?: React.ReactNode; center?: boolean }) {
  if (!title && !eyebrow) return null;
  return (
    <div className={cn('mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between', center && 'items-center text-center lg:flex-col lg:items-center')}>
      <div className="max-w-[40rem]">
        {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>
  );
}

/* ------------------------------------------------------------------ */
/* cover-story                                                         */
/* ------------------------------------------------------------------ */

function CoverStory(p: MagazineFrontProps & { t: Texts }) {
  const { t } = p;
  const [lead, ...rest] = p.stories ?? [];
  const side = rest.slice(0, 3);
  const row = rest.slice(3, 7);
  if (!lead) return 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={p.date && <p className="text-[14px] text-muted-foreground lg:text-right">{p.date}</p>} />
        <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 border-t border-border pt-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] lg:pt-10">
          <Reveal effect="rise">
            <StoryLink s={lead}>
              <Picture media={lead.media} aspect="16 / 10" parallax />
              <Kicker className="mt-6">{lead.section}</Kicker>
              <h3 className={cn('mt-2.5 text-balance text-[30px] font-semibold leading-[1.1] tracking-[-0.022em] text-foreground sm:text-[40px] lg:text-[46px]', TITLE_HOVER)}>{lead.title}</h3>
              {lead.dek && <p className="mt-4 max-w-[38rem] text-pretty text-[17px] leading-[1.6] text-muted-foreground sm:text-[18px]">{lead.dek}</p>}
              <Meta s={lead} t={t} withAuthor className="mt-4" />
            </StoryLink>
          </Reveal>
          <Stagger as="ol" step={90} className="divide-y divide-border border-t border-border lg:border-t-0 lg:border-l lg:pl-10">
            {side.map((s, i) => (
              <li key={i} className={cn('py-6', i === 0 && 'lg:pt-0')}>
                <StoryLink s={s} className="grid grid-cols-[minmax(0,1fr)_6rem] items-start gap-4 sm:grid-cols-[minmax(0,1fr)_8rem] lg:grid-cols-[minmax(0,1fr)]">
                  <div className="min-w-0">
                    <Kicker>{s.section}</Kicker>
                    <h3 className={cn('mt-2 text-pretty text-[19px] font-semibold leading-[1.3] tracking-[-0.012em] text-foreground', TITLE_HOVER)}>{s.title}</h3>
                    {s.dek && <p className="mt-2 hidden text-pretty text-[14.5px] leading-[1.55] text-muted-foreground sm:block">{s.dek}</p>}
                    <Meta s={s} t={t} className="mt-2.5" />
                  </div>
                  {i === 0 ? <Picture media={s.media} aspect="4 / 3" className="lg:order-first lg:mb-4" /> : <Picture media={s.media} aspect="4 / 3" className="lg:hidden" />}
                </StoryLink>
              </li>
            ))}
          </Stagger>
        </div>
        {row.length > 0 && (
          <Stagger step={70} className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-x-8 border-t border-border sm:grid-cols-2 lg:mt-14 lg:grid-cols-4">
            {row.map((s, i) => (
              <div key={i} className="border-b border-border py-6 sm:pr-2 lg:border-b-0 lg:pt-7">
                <StoryLink s={s}>
                  <Kicker>{s.section}</Kicker>
                  <h3 className={cn('mt-2 text-pretty text-[17px] font-semibold leading-[1.35] text-foreground', TITLE_HOVER)}>{s.title}</h3>
                  <Meta s={s} t={t} className="mt-2.5" />
                </StoryLink>
              </div>
            ))}
          </Stagger>
        )}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* sections                                                            */
/* ------------------------------------------------------------------ */

function Sections(p: MagazineFrontProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const sections = p.sections ?? [];
  const [active, setActive] = useState(0);
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const bar = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ x: number; w: number } | null>(null);

  useEffect(() => {
    const els = sections.map((_, i) => document.getElementById(`${uid}-s${i}`)).filter(Boolean) as HTMLElement[];
    const io = new IntersectionObserver(
      (entries) => {
        const vis = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
        if (vis[0]) setActive(Number(vis[0].target.getAttribute('data-i')));
      },
      { rootMargin: '-30% 0px -60% 0px' },
    );
    els.forEach((e) => io.observe(e));
    return () => io.disconnect();
  }, [sections.length, uid]);

  useLayoutEffect(() => {
    const a = bar.current?.querySelector<HTMLElement>('[aria-current="true"]');
    if (!a) return;
    setMark({ x: a.offsetLeft, w: a.offsetWidth });
    const row = rowRef.current;
    if (row && (a.offsetLeft < row.scrollLeft || a.offsetLeft + a.offsetWidth > row.scrollLeft + row.clientWidth)) row.scrollTo({ left: a.offsetLeft - 24, behavior: 'smooth' });
  }, [active, rowRef]);

  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} />
      </div>
      <nav aria-label={t.sections} className="sticky top-0 z-20 border-y border-border bg-[color-mix(in_oklab,var(--background)_90%,transparent)] backdrop-blur-md">
        <div className="kit-container">
          <div ref={rowRef} className={cn('kit-no-scrollbar relative -m-1 overflow-x-auto p-1 [&_a:focus-visible]:outline-offset-[-2px]', fade)}>
            <div ref={bar} className="relative flex w-max gap-1">
              {sections.map((s, i) => (
                <a
                  key={i}
                  href={`#${uid}-s${i}`}
                  aria-current={active === i ? 'true' : undefined}
                  onClick={() => setActive(i)}
                  className={cn('relative flex min-h-12 items-center whitespace-nowrap px-3 text-[14px] font-medium transition-colors duration-300 first:-ml-3', active === i ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
                >
                  {s.name}
                  {!mark && active === i && <span aria-hidden className="absolute inset-x-3 bottom-0 h-0.5 bg-primary" />}
                </a>
              ))}
              {mark && <span aria-hidden className="absolute bottom-0 left-0 h-0.5 bg-primary transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: mark.w - 24, transform: `translateX(${mark.x + 12}px)` }} />}
            </div>
          </div>
        </div>
      </nav>
      <div className="kit-container">
        {sections.map((sec, i) => {
          const [lead, ...more] = sec.stories;
          const flip = i % 2 === 1;
          return (
            <section key={i} id={`${uid}-s${i}`} data-i={i} aria-labelledby={`${uid}-h${i}`} className="scroll-mt-16 border-b border-border py-12 last:border-b-0 sm:py-14">
              <div className="mb-7 flex items-baseline justify-between gap-4">
                <h3 id={`${uid}-h${i}`} className="text-[24px] font-semibold tracking-[-0.015em] text-foreground sm:text-[28px]">
                  {sec.name}
                </h3>
                {sec.href && (
                  <a href={sec.href} className="group/all inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium text-muted-foreground transition-colors hover:text-foreground">
                    <span className="hidden sm:inline">{t.all.replace('{name}', sec.name)}</span>
                    <span className="sm:hidden">{sec.name}</span>
                    <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-0.5" />
                  </a>
                )}
              </div>
              <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-8 lg:grid-cols-2">
                {lead && (
                  <Reveal effect="rise" className={cn(flip && 'lg:order-last')}>
                    <StoryLink s={lead}>
                      <Picture media={lead.media} aspect="16 / 10" />
                      <h4 className={cn('mt-5 text-balance text-[24px] font-semibold leading-[1.2] tracking-[-0.016em] text-foreground sm:text-[28px]', TITLE_HOVER)}>{lead.title}</h4>
                      {lead.dek && <p className="mt-3 text-pretty text-[16px] leading-[1.6] text-muted-foreground">{lead.dek}</p>}
                      <Meta s={lead} t={t} withAuthor className="mt-3" />
                    </StoryLink>
                  </Reveal>
                )}
                <Stagger as="ul" step={80} className="divide-y divide-border">
                  {more.slice(0, 3).map((s, j) => (
                    <li key={j} className={cn('py-5', j === 0 && 'pt-0 lg:pt-0')}>
                      <StoryLink s={s} className="grid grid-cols-[minmax(0,1fr)_5.5rem] items-start gap-4 sm:grid-cols-[minmax(0,1fr)_9rem]">
                        <div className="min-w-0">
                          <h4 className={cn('text-pretty text-[17px] font-semibold leading-[1.35] text-foreground sm:text-[18px]', TITLE_HOVER)}>{s.title}</h4>
                          {s.dek && <p className="mt-1.5 line-clamp-2 hidden text-pretty text-[14.5px] leading-[1.55] text-muted-foreground sm:block">{s.dek}</p>}
                          <Meta s={s} t={t} className="mt-2" />
                        </div>
                        <Picture media={s.media} aspect="4 / 3" />
                      </StoryLink>
                    </li>
                  ))}
                </Stagger>
              </div>
            </section>
          );
        })}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* issue                                                               */
/* ------------------------------------------------------------------ */

function Issue(p: MagazineFrontProps & { t: Texts }) {
  const { t } = p;
  const stories = p.stories ?? [];
  const lines = stories.map((s, i) => ({ s, i })).filter((x) => x.s.onCover);
  const [hot, setHot] = useState<number | null>(null);
  return (
    <section className={cn('overflow-x-clip py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container grid grid-cols-[minmax(0,1fr)] items-start gap-12 md:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)] lg:gap-20">
        <div className="md:sticky md:top-10">
          <Reveal effect="scale" className="mx-auto max-w-[22rem] md:max-w-none">
            <Parallax speed={28}>
              {/* The cover: masthead, issue line, picture and cover lines */}
              <div data-clip-ok className="relative isolate flex aspect-[3/4] flex-col overflow-hidden rounded-lg bg-primary p-[7%] text-white shadow-[var(--shadow-lg)] [container-type:inline-size]">
                {p.cover && <div className="absolute inset-0 -z-10 opacity-90 [&>*]:size-full">{p.cover}</div>}
                <div className="absolute inset-0 -z-10 bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_70%,rgb(0_0_0/0.35))_0%,transparent_38%,transparent_50%,rgb(0_0_0/0.72)_100%)]" />
                <div className="flex items-baseline justify-between text-[3.6cqw] font-medium opacity-90">
                  <span>{p.issue}</span>
                  <span>{p.date}</span>
                </div>
                <p className="mt-[2cqw] text-[17cqw] font-bold leading-[0.86] tracking-[-0.05em] [text-shadow:0_2px_24px_rgb(0_0_0/0.18)]">{p.masthead}</p>
                <ul className="mt-auto space-y-[2.6cqw]">
                  {lines.map(({ s, i }) => (
                    <li key={i} className={cn('flex gap-[2.4cqw] transition-opacity duration-300', hot === null || hot === i ? 'opacity-100' : 'opacity-45')}>
                      <span className={cn('mt-[1cqw] h-[0.6cqw] w-[5cqw] shrink-0 rounded-full bg-current transition-transform duration-300 origin-left', hot === i ? 'scale-x-150' : 'scale-x-100')} aria-hidden />
                      <span className="text-[5.2cqw] font-semibold leading-[1.12] tracking-[-0.015em] [text-shadow:0_1px_12px_rgb(0_0_0/0.25)]">{s.title}</span>
                    </li>
                  ))}
                </ul>
              </div>
            </Parallax>
          </Reveal>
        </div>
        <div className="min-w-0">
          {p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
          {p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
          {p.description && <p className="kit-rise mt-4 max-w-[36rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
          {(p.primaryAction || p.secondaryAction) && (
            <div className="kit-rise mt-7 flex flex-col gap-3 sm:flex-row sm:flex-wrap" style={{ '--kit-delay': '180ms' } as React.CSSProperties}>
              {p.primaryAction && (
                <Magnetic className="max-sm:flex max-sm:w-full">
                  <Button href={p.primaryAction.href} icon="arrow-right" className="max-sm:w-full">{p.primaryAction.label}</Button>
                </Magnetic>
              )}
              {p.secondaryAction && <Button href={p.secondaryAction.href} variant="outline" className="max-sm:w-full">{p.secondaryAction.label}</Button>}
            </div>
          )}
          <h3 className="mt-12 text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.contents}</h3>
          <Stagger as="ol" step={60} className="mt-4 divide-y divide-border border-y border-border">
            {stories.map((s, i) => (
              <li key={i}>
                <a
                  href={s.href ?? '#'}
                  onPointerEnter={() => setHot(i)}
                  onPointerLeave={() => setHot(null)}
                  onFocus={() => setHot(i)}
                  onBlur={() => setHot(null)}
                  className="group/story -mx-3 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-x-4 rounded-lg px-3 py-5 transition-colors duration-300 hover:bg-surface sm:grid-cols-[4rem_minmax(0,1fr)] lg:grid-cols-[4rem_minmax(0,1fr)_auto]"
                >
                  <span className="pt-0.5 text-[15px] font-semibold tabular-nums text-foreground" aria-label={s.page ? t.pageLong.replace('{n}', String(s.page)) : undefined}>
                    {s.page ? String(s.page).padStart(2, '0') : ''}
                  </span>
                  <span className="min-w-0">
                    <Kicker>{s.section}</Kicker>
                    <span className={cn('mt-1.5 block text-pretty text-[18px] font-semibold leading-[1.3] tracking-[-0.01em] text-foreground', TITLE_HOVER)}>{s.title}</span>
                    {s.dek && <span className="mt-1.5 block text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{s.dek}</span>}
                  </span>
                  <span className="col-start-2 mt-2 text-[13px] text-muted-foreground lg:col-start-3 lg:mt-0.5 lg:text-right">
                    {s.author}
                    {s.onCover && <span className="mt-1 hidden items-center justify-end gap-1 text-[12px] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] lg:flex"><Icon name="book-open" size={13} />{t.onCover}</span>}
                  </span>
                </a>
              </li>
            ))}
          </Stagger>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* digest                                                              */
/* ------------------------------------------------------------------ */

function Digest(p: MagazineFrontProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const stories = p.stories ?? [];
  const [read, setRead] = useState<Set<number>>(new Set());
  const [open, setOpen] = useState<number | null>(null);
  const total = stories.reduce((a, s) => a + (s.readingTime ?? 0), 0);
  const share = stories.length ? read.size / stories.length : 0;
  const R = 15;
  const C = 2 * Math.PI * R;
  const toggleRead = (i: number) =>
    setRead((s) => {
      const n = new Set(s);
      if (n.has(i)) n.delete(i);
      else n.add(i);
      return n;
    });
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mx-auto max-w-[46rem]">
          <header className="border-b border-border pb-8">
            <div className="flex items-center justify-between gap-4 text-[13px] text-muted-foreground">
              <span className="kit-rise font-semibold text-foreground">{p.masthead}</span>
              <span className="kit-rise tabular-nums">{p.date}</span>
            </div>
            {p.title && <h2 className="kit-rise mt-8 text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
            {p.description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
            <div className="kit-rise mt-6 flex items-center gap-3" style={{ '--kit-delay': '180ms' } as React.CSSProperties}>
              <svg viewBox="0 0 36 36" className="size-9 -rotate-90" aria-hidden>
                <circle cx="18" cy="18" r={R} fill="none" stroke="var(--border)" strokeWidth="3" />
                <circle cx="18" cy="18" r={R} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" strokeDasharray={round(C)} strokeDashoffset={round(C * (1 - share))} className="transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" />
              </svg>
              <p className="text-[14px] text-muted-foreground" aria-live="polite">
                <span className="font-medium text-foreground">{read.size === stories.length && stories.length ? t.done : t.progress.replace('{a}', String(read.size)).replace('{b}', String(stories.length))}</span>
                {total > 0 && <span> · {t.minutes.replace('{n}', String(total))}</span>}
              </p>
            </div>
          </header>
          <Stagger as="ol" step={80}>
            {stories.map((s, i) => {
              const isRead = read.has(i);
              const isOpen = open === i;
              return (
                <li key={i} className="border-b border-border py-7">
                  <div className="grid grid-cols-[2.25rem_minmax(0,1fr)_auto] gap-x-3 sm:grid-cols-[3rem_minmax(0,1fr)_auto] sm:gap-x-4">
                    <span className={cn('mt-[25px] text-[22px] font-semibold leading-[1.15] tabular-nums transition-colors duration-500 sm:text-[26px] sm:leading-[1.05]', isRead ? 'text-subtle-foreground' : 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')} aria-hidden>
                      {i + 1}
                    </span>
                    <div className="min-w-0">
                      <Kicker>{s.section}</Kicker>
                      <h3 className={cn('mt-1.5 text-pretty text-[19px] font-semibold leading-[1.3] tracking-[-0.01em] transition-colors duration-500 sm:text-[21px]', isRead ? 'text-muted-foreground' : 'text-foreground')}>
                        <a href={s.href ?? '#'} className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-current">
                          {s.title}
                        </a>
                      </h3>
                      {s.dek && <p className="mt-2 text-pretty text-[15.5px] leading-[1.6] text-muted-foreground">{s.dek}</p>}
                      {s.why && (
                        <>
                          <button type="button" aria-expanded={isOpen} aria-controls={`${uid}-w${i}`} onClick={() => setOpen(isOpen ? null : i)} className="mt-3 inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium text-foreground transition-colors hover:text-primary">
                            <Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', isOpen && 'rotate-180')} />
                            {t.why}
                          </button>
                          <div id={`${uid}-w${i}`} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                            <div className="min-h-0 overflow-hidden" inert={!isOpen}>
                              <p className="mt-1 border-l-2 border-primary pl-4 text-pretty text-[15px] leading-[1.6] text-foreground">{s.why}</p>
                            </div>
                          </div>
                        </>
                      )}
                    </div>
                    <button
                      type="button"
                      aria-pressed={isRead}
                      aria-label={`${isRead ? t.unread : t.markRead}, ${s.title}`}
                      onClick={() => toggleRead(i)}
                      className={cn('-mt-1.5 grid size-11 place-items-center rounded-full border transition-[background-color,border-color,color,transform] duration-300 active:scale-95', isRead ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}
                    >
                      <Icon name="check" size={17} />
                    </button>
                  </div>
                </li>
              );
            })}
          </Stagger>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* most-read                                                           */
/* ------------------------------------------------------------------ */

function MostRead(p: MagazineFrontProps & { t: Texts }) {
  const { t } = p;
  const periods = p.periods ?? [];
  const [pi, setPi] = useState(0);
  const seg = useRef<HTMLDivElement>(null);
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
  const [listRef, inView] = useInViewOnce<HTMLDivElement>();
  useLayoutEffect(() => {
    const b = seg.current?.querySelector<HTMLElement>('[aria-checked="true"]');
    if (b) setPill({ x: b.offsetLeft, w: b.offsetWidth });
  }, [pi]);
  const stories = periods[pi]?.stories ?? [];
  const max = Math.max(1, ...stories.map((s) => s.reads ?? 0));
  const locale = useLocale();
  const fmt = (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
  const onKey = (e: React.KeyboardEvent) => {
    if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
    e.preventDefault();
    const n = (pi + (e.key === 'ArrowRight' ? 1 : periods.length - 1)) % periods.length;
    setPi(n);
    seg.current?.querySelectorAll<HTMLElement>('[role="radio"]')[n]?.focus();
  };
  const half = Math.ceil(stories.length / 2);
  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={
            periods.length > 1 && (
              <div ref={seg} role="radiogroup" aria-label={t.period} onKeyDown={onKey} className="relative inline-flex self-start rounded-xl border border-border bg-surface p-1 lg:self-end">
                {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)` }} />}
                {periods.map((x, i) => (
                  <button key={i} type="button" role="radio" aria-checked={pi === i} tabIndex={pi === i ? 0 : -1} onClick={() => setPi(i)} className={cn('relative min-h-11 rounded-lg px-4 text-[14px] font-medium transition-colors duration-300', pi === i ? cn('text-foreground', !pill && 'bg-card shadow-[var(--shadow-sm)]') : 'text-muted-foreground hover:text-foreground')}>
                    {x.label}
                  </button>
                ))}
              </div>
            )
          }
        />
        <div ref={listRef} key={pi} className="grid grid-cols-[minmax(0,1fr)] gap-x-12 border-t border-border lg:grid-cols-2">
          {[stories.slice(0, half), stories.slice(half)].map((col, c) => (
            <ol key={c} start={c * half + 1}>
              {col.map((s, j) => {
                const i = c * half + j;
                return (
                  <li key={i} className="kit-rise border-b border-border" style={{ '--kit-delay': `${i * 45}ms` } as React.CSSProperties}>
                    <StoryLink s={s} className="grid grid-cols-[3rem_minmax(0,1fr)_4.5rem] items-center gap-4 py-5 sm:grid-cols-[3.5rem_minmax(0,1fr)_6rem]">
                      <span className="text-[38px] font-semibold leading-none tracking-[-0.04em] text-muted-foreground tabular-nums transition-colors duration-300 group-hover/story:text-primary sm:text-[44px]" aria-label={t.rank.replace('{n}', String(i + 1))}>
                        {i + 1}
                      </span>
                      <span className="min-w-0">
                        <Kicker className="text-[11.5px]">{s.section}</Kicker>
                        <span className={cn('mt-1 block text-pretty text-[16px] font-semibold leading-[1.35] text-foreground sm:text-[17px]', TITLE_HOVER)}>{s.title}</span>
                        {s.reads !== undefined && (
                          <span className="mt-2.5 flex items-center gap-3">
                            <span className="h-1 flex-1 overflow-hidden rounded-full bg-track" aria-hidden>
                              <span className="block h-full origin-left rounded-full bg-[color-mix(in_oklab,var(--primary)_75%,var(--background))] transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round((s.reads ?? 0) / max, 3) : 0})`, transitionDelay: `${200 + i * 60}ms` }} />
                            </span>
                            <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{t.reads.replace('{n}', fmt(s.reads))}</span>
                          </span>
                        )}
                      </span>
                      <Picture media={s.media} aspect="1 / 1" />
                    </StoryLink>
                  </li>
                );
              })}
            </ol>
          ))}
        </div>
      </div>
    </section>
  );
}

/** Like useInView, but starts false only in the browser once mounted below the fold */
function useInViewOnce<T extends Element>() {
  const ref = useRef<T>(null);
  const [inView, setInView] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
    setInView(false);
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) {
        setInView(true);
        io.disconnect();
      }
    }, { rootMargin: '0px 0px -10% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, inView] as const;
}

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

export function MagazineFront(props: MagazineFrontProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'cover-story' } = props;
  if (variant === 'sections') return <Sections {...props} t={t} />;
  if (variant === 'issue') return <Issue {...props} t={t} />;
  if (variant === 'digest') return <Digest {...props} t={t} />;
  if (variant === 'most-read') return <MostRead {...props} t={t} />;
  return <CoverStory {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Titelseite?

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

Welche Varianten hat Titelseite?

5 Varianten, Aufmacher, Ressorts, Printausgabe, Morgenüberblick und Meistgelesen. Du wählst sie mit der Prop variant, zum Beispiel variant="sections".

Kann ich die Texte von Titelseite übersetzen?

Ja. Titelseite bringt 17 eigene Texte auf Deutsch und Englisch mit, etwa „Ressorts“, „In dieser Ausgabe“ und „Als gelesen markieren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Titelseite einbauen?

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

Kann ich Titelseite ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert