Skip to content
UI Kit
Accent color

Magazine front

The front of a magazine or news site, sorted like an editor would. Lead story, sections with a bar that follows, a print issue with contents, a morning digest to tick off and the most read by period.

One of 3 components in Editorial

5 variants

Variants

Cover story

One lead story with a large picture that drifts while scrolling, three side stories and a row of short ones.

Open in Studio

Sections

The front by section. The section bar sticks and its line glides to the section being read, the lead changes sides from section to section.

Open in Studio

Print issue

The cover with masthead and cover lines next to the contents. A story in the contents lights up its line on the cover.

Open in Studio

Morning digest

A handful of stories to tick off, a ring counts what is read. Each story opens a short why it matters.

Open in Studio

Most read

Ranks by period with a gliding switch. A bar under each story fills to its reads once the list scrolls in.

Open in Studio

Details

What Magazine front brings along, read from the registry and the source.

Category
Editorial
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/magazine-front.tsx
Brings along
utilskitbuttoniconrevealmotionsection
npm packages
None
Languages
English and German, 17 texts of its own
In collections
Blog or magazine

Props

The MagazineFrontProps interface from components/ui/magazine-front.tsx, with the comments from the code.

  • 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 }

Built-in texts

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

Show all 17 texts
minutesEnglish {n} minGerman {n} Min.
byEnglish By {name}German Von {name}
allEnglish All in {name}German Alles aus {name}
sectionsEnglish SectionsGerman Ressorts
contentsEnglish In this issueGerman In dieser Ausgabe
pageEnglish p. {n}German S. {n}
pageLongEnglish Page {n}German Seite {n}
markReadEnglish Mark as readGerman Als gelesen markieren
unreadEnglish Mark as unreadGerman Als ungelesen markieren
whyEnglish Why it mattersGerman Warum es zählt
progressEnglish {a} of {b} readGerman {a} von {b} gelesen
doneEnglish You are up to date.German Du bist auf dem neuesten Stand.
readsEnglish {n} readsGerman {n} Aufrufe
rankEnglish Rank {n}German Platz {n}
periodEnglish PeriodGerman Zeitraum
moreEnglish More storiesGerman Weitere Geschichten
onCoverEnglish On the coverGerman Auf dem Titel

Accessibility

Read from the source of the component.

  • Magazine front handles the keys Arrow left and Arrow right itself.
  • Sets aria-checked, aria-controls, aria-current, aria-expanded, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles radio and radiogroup.
  • Announces changes through a live region without moving focus.
  • 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).
  • With reduced motion (prefers-reduced-motion) it stays still. 6 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, 670 lines
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} />;
}

Frequent questions

How do I install Magazine front?

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

Which variants does Magazine front have?

5 variants, Cover story, Sections, Print issue, Morning digest and Most read. You pick one with the variant prop, for example variant="sections".

Can I translate the texts of Magazine front?

Yes. Magazine front carries 17 texts of its own in English and German, such as “Sections”, “In this issue” and “Mark as read”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Magazine front?

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

Can I adjust Magazine front without code?

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

Often searched

Jump to

Copied to the clipboard