# Newsletter signup

> An email signup framed by a stack of past issues; subscribing drops the next issue, addressed to you, onto the front.

A shadcn/ui block from the [@uiarc](https://uiarc.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiarc/newsletter-signup).

- Page, with a live preview: https://shadcn.exchange/r/uiarc/newsletter-signup
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiarc/newsletter-signup-light-e91cad2fa8d5444e.png
- Registry item: https://uiarc.dev/r/newsletter-signup.json
- Categories: blocks
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiarc.dev/r/newsletter-signup.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `https://uiarc.dev/r/arc-motion-tokens.json`, `https://uiarc.dev/r/animated-counter.json`, `https://uiarc.dev/r/segmented-control.json`, `https://uiarc.dev/r/switch.json`

## Notes from the registry

Use this to collect newsletter or waitlist emails. Pass onSubscribe to call your email provider and throw on failure; subscribing in the preview is simulated.

## Files

### `registry/blocks/newsletter-signup/newsletter-signup.tsx`

```tsx
"use client";

import { forwardRef, useEffect, useId, useRef, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import Image from "next/image";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import type { Variants } from "motion/react";
import { ArrowRight } from "lucide-react";
import AnimatedCounter from "../../animated-counter/animated-counter";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Switch } from "../../switch/switch";
import { motionTokens } from "../../lib/motion-tokens";
import { newsletterCopy, newsletterPublication, newsletterReaders } from "./newsletter-signup-data";
import type { NewsletterIssue, NewsletterPublication } from "./newsletter-signup-data";
import styles from "./newsletter-signup.module.css";

export type { NewsletterIssue, NewsletterPublication, NewsletterStory } from "./newsletter-signup-data";
export type NewsletterVariant = "inline" | "card";

export interface NewsletterSignupProps {
  /** `inline` puts the copy and form beside the issue stack; `card` is a self-contained card with the stack in a tray on top. */
  variant?: NewsletterVariant;
  title?: string;
  description?: string;
  placeholder?: string;
  buttonLabel?: string;
  /** Short line under the form about frequency and privacy. */
  privacyNote?: ReactNode;
  /** Link after the privacy note. Pass null to hide it. */
  privacyLink?: { label: string; href: string } | null;
  /** Reader count and up to three faces. The count ticks up by one when someone subscribes. Pass null to hide it. */
  readers?: { count: number; faces: string[] } | null;
  /** The issue stack. On success the upcoming issue, addressed to the new reader, lands on top. Pass null for a form without the stack. */
  publication?: NewsletterPublication | null;
  /** Called with a valid, trimmed email. Reject to show an error and keep the email; resolve to show the success state. */
  onSubscribe?: (email: string) => void | Promise<void>;
  className?: string;
}

type Phase = "idle" | "sending" | "done";
type Problem = { kind: "invalid" | "failed"; text: string };
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
const MESSAGES = {
  empty: "Enter your email address",
  format: "Enter an email like name@company.com",
  failed: "That didn't go through. Your email is kept, so try again.",
};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** How far each issue behind the front one peeks out above it. */
const STEP = 12;

function validate(value: string): Problem | null {
  const email = value.trim();
  if (!email) return { kind: "invalid", text: MESSAGES.empty };
  if (!EMAIL.test(email)) return { kind: "invalid", text: MESSAGES.format };
  return null;
}

const swapIn = { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` };
const swapShown = { opacity: 1, y: 0, filter: "blur(0px)" };
const swapOut = { opacity: 0, y: -6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };
const fadeIn = { opacity: 0 };
const fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };

/** A line that swaps in place: the old text lifts away and the new one rises out of a soft blur. */
function Swap({ id, children, reduced, className, live }: { id: string; children: ReactNode; reduced: boolean; className?: string; live?: "polite" }) {
  return <div className={className} aria-live={live}>
    <AnimatePresence initial={false} mode="popLayout">
      <motion.div key={id} initial={reduced ? fadeIn : swapIn} animate={swapShown} exit={reduced ? fadeOut : swapOut} transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }}>{children}</motion.div>
    </AnimatePresence>
  </div>;
}

/** One issue: masthead, recipient line, subject, and three stories with thumbnails. */
function IssueCard({ issue, name, to }: { issue: NewsletterIssue; name: string; to?: string }) {
  return <>
    <header className={styles.issueHead}>
      <span className={styles.masthead}>{name}</span>
      <span className={styles.issueNumber}>Issue {issue.number}</span>
    </header>
    <p className={styles.issueMeta}>
      <span className={styles.issueDate}>{issue.date}</span>
      {to && <><span aria-hidden="true">&middot;</span><span className={styles.recipient}>To <span className={styles.recipientEmail}>{to}</span></span></>}
    </p>
    <h3 className={styles.subject}>{issue.subject}</h3>
    <ul className={styles.stories}>
      {issue.stories.slice(0, 3).map(story => <li key={story.title} className={styles.story}>
        <Image className={styles.thumb} src={story.image} alt="" width={88} height={88} sizes="44px" />
        <span className={styles.storyTitle}>{story.title}</span>
        <span className={styles.minutes}>{story.minutes} min</span>
      </li>)}
    </ul>
  </>;
}

type Placement = { depth: number; upcoming: boolean };
/** The front issue sits flat; each one behind steps up and shrinks a little so its top edge shows. */
const stackVariants: Variants = {
  placed: ({ depth }: Placement) => ({ opacity: 1, y: -depth * STEP, scale: 1 - depth * .045 }),
  // The upcoming issue arrives from below, where the form is; older ones slide in from behind.
  away: ({ upcoming }: Placement) => upcoming ? { opacity: 0, y: 56, scale: 1 } : { opacity: 0, y: -3 * STEP, scale: 1 - 3 * .045 },
};
/** Position rides the no-overshoot spring; opacity resolves fast so two issues never read through each other. */
const settle = { ...motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant, ease: standard } };
/** Reduced motion: issues take their places at once and only fade. */
const still = { duration: 0, opacity: { duration: motionTokens.duration.fast } };

/**
 * A stack of recent issues. When someone subscribes, the upcoming issue, addressed to them, lands on the front
 * and the older ones step back; the oldest drops away.
 */
function IssueStack({ publication, delivered, to, reduced, compact }: { publication: NewsletterPublication; delivered: boolean; to: string; reduced: boolean; compact?: boolean }) {
  const issues = (delivered ? [publication.upcoming, ...publication.recent] : publication.recent).slice(0, 3);
  return <div className={styles.stack} data-compact={compact ? "" : undefined}>
    {/* An unseen copy of the upcoming issue keeps the stack as tall as the tallest issue it will ever hold. */}
    <div className={`${styles.issue} ${styles.issueSizer}`} aria-hidden="true"><IssueCard issue={publication.upcoming} name={publication.name} to={to || " "} /></div>
    <AnimatePresence initial={false}>
      {issues.map((issue, depth) => {
        const upcoming = issue === publication.upcoming;
        const placement: Placement = { depth, upcoming };
        return <motion.article
          key={issue.number}
          className={styles.issue}
          data-depth={depth}
          aria-hidden={depth > 0 ? true : undefined}
          aria-label={depth === 0 ? `${publication.name}, issue ${issue.number}` : undefined}
          custom={placement}
          variants={stackVariants}
          initial="away"
          animate="placed"
          exit="away"
          transition={reduced ? still : settle}
          style={{ zIndex: 3 - depth }}
        >
          <IssueCard issue={issue} name={publication.name} to={upcoming ? to : undefined} />
        </motion.article>;
      })}
    </AnimatePresence>
  </div>;
}

/** Faces and a reader count that ticks up by one when you join. */
function Readers({ readers, done, reduced }: { readers: { count: number; faces: string[] }; done: boolean; reduced: boolean }) {
  return <div className={styles.readers}>
    <span className={styles.faces} aria-hidden="true">
      {readers.faces.slice(0, 3).map(src => <Image key={src} src={src} alt="" width={56} height={56} sizes="28px" />)}
    </span>
    <span className={styles.readerLine}>
      <span className={styles.count}><AnimatedCounter value={readers.count + (done ? 1 : 0)} /></span>
      {" "}readers
      <AnimatePresence initial={false}>
        {/* The phrase opens its own width on the no-overshoot spring, so the centred line glides instead of jumping sideways. */}
        {done && <motion.span key="you" className={styles.you}
          initial={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}
          animate={reduced ? { opacity: 1 } : { opacity: 1, width: "auto" }}
          exit={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}
          transition={reduced ? { duration: motionTokens.duration.instant } : { width: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .12 } }}>, including you</motion.span>}
      </AnimatePresence>
    </span>
  </div>;
}

/**
 * A newsletter signup framed by the newsletter itself: a stack of recent issues. Subscribing drops the next issue,
 * addressed to the new reader, onto the front of the stack and ticks the reader count up by one.
 * Comes as an inline page section or a self-contained card.
 */
export const NewsletterSignup = forwardRef<HTMLElement, NewsletterSignupProps>(function NewsletterSignup({
  variant = "inline",
  title,
  description,
  placeholder = "you@company.com",
  buttonLabel = "Subscribe",
  privacyNote = newsletterCopy.privacy,
  privacyLink = newsletterCopy.privacyLink,
  readers = newsletterReaders,
  publication = newsletterPublication,
  onSubscribe,
  className,
}, ref) {
  const id = useId();
  const reduced = !!useReducedMotion();
  const [email, setEmail] = useState("");
  const [problem, setProblem] = useState<Problem | null>(null);
  const [tried, setTried] = useState(false);
  const [phase, setPhase] = useState<Phase>("idle");
  const [sentTo, setSentTo] = useState("");
  const input = useRef<HTMLInputElement>(null);
  const again = useRef<HTMLButtonElement>(null);
  const busy = useRef(false);
  const refocus = useRef(false);
  const [pill, animatePill] = useAnimate<HTMLDivElement>();
  const copy = newsletterCopy[variant];

  useEffect(() => {
    if (phase === "idle" && refocus.current) { refocus.current = false; input.current?.focus(); }
    if (phase === "done") again.current?.focus();
  }, [phase]);

  function shake() {
    if (reduced || !pill.current) return;
    animatePill(pill.current, { x: [0, -6, 5, -3, 1, 0] }, { duration: .36, ease: "easeOut" });
  }

  async function submit(event: FormEvent) {
    event.preventDefault();
    if (busy.current || phase !== "idle") return;
    setTried(true);
    const found = validate(email);
    setProblem(found);
    if (found) { shake(); input.current?.focus(); return; }
    const value = email.trim();
    busy.current = true;
    setPhase("sending");
    try {
      await (onSubscribe ? onSubscribe(value) : new Promise(resolve => setTimeout(resolve, 1100)));
      setSentTo(value);
      setPhase("done");
    } catch {
      setPhase("idle");
      setProblem({ kind: "failed", text: MESSAGES.failed });
      shake();
    } finally {
      busy.current = false;
    }
  }

  function reset() {
    setEmail("");
    setProblem(null);
    setTried(false);
    refocus.current = true;
    setPhase("idle");
  }

  const done = phase === "done";
  const sending = phase === "sending";
  const messageId = `${id}-message`;
  const labelKey = done ? "done" : sending ? "sending" : problem?.kind === "failed" ? "retry" : "idle";
  const labels: Record<string, ReactNode> = {
    idle: <>{buttonLabel}<ArrowRight size={16} strokeWidth={1.75} aria-hidden="true" /></>,
    retry: <>Try again<ArrowRight size={16} strokeWidth={1.75} aria-hidden="true" /></>,
    sending: <><span className={styles.spinner} aria-hidden="true" />Subscribing</>,
    done: <><span className={styles.check} aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduced ? 0 : .34, ease: enter, delay: .12 }} /></svg></span>Subscribed</>,
  };

  const doneText = "Check your inbox to confirm.";
  const note = <>{privacyNote}{privacyLink && <> <a className={styles.link} href={privacyLink.href}>{privacyLink.label}</a></>}</>;
  // Plain copies of the note and the confirmation, used only to reserve the line's height.
  const measures = [<>{privacyNote}{privacyLink && ` ${privacyLink.label}`}</>, `${doneText} Use a different email`, MESSAGES.empty, MESSAGES.format, MESSAGES.failed];
  const form = <form className={styles.form} onSubmit={submit} noValidate aria-label={title ?? copy.title}>
    <motion.div ref={pill} className={styles.pill} data-invalid={problem ? "" : undefined} data-done={done ? "" : undefined}>
      <label htmlFor={`${id}-email`} className={styles.srOnly}>Email address</label>
      <input
        ref={input}
        id={`${id}-email`}
        className={styles.input}
        type="email"
        name="email"
        inputMode="email"
        autoComplete="email"
        enterKeyHint="send"
        autoCapitalize="off"
        spellCheck={false}
        placeholder={placeholder}
        value={email}
        readOnly={phase !== "idle"}
        aria-invalid={problem?.kind === "invalid" ? true : undefined}
        aria-describedby={messageId}
        onBlur={() => { if (email.trim() && !tried && phase === "idle") { setTried(true); setProblem(validate(email)); } }}
        onChange={event => { setEmail(event.target.value); if (tried) setProblem(validate(event.target.value)); else if (problem) setProblem(null); }}
      />
      <motion.button
        type={done ? "button" : "submit"}
        className={styles.submit}
        data-state={labelKey}
        aria-busy={sending || undefined}
        aria-disabled={sending || done || undefined}
        tabIndex={done ? -1 : undefined}
        whileTap={{ scale: reduced || sending || done ? 1 : .97 }}
        transition={motionTokens.spring.snappy}
      >
        <span className={styles.labels}>
          {Object.keys(labels).map(key => <span key={key} className={styles.sizer} aria-hidden="true">{key === "done" ? <><span className={styles.check} />Subscribed</> : labels[key]}</span>)}
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span key={labelKey} className={styles.label} initial={reduced ? fadeIn : swapIn} animate={swapShown} exit={reduced ? fadeOut : swapOut} transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }}>
              {labels[labelKey]}
            </motion.span>
          </AnimatePresence>
        </span>
      </motion.button>
    </motion.div>
    <div className={styles.message}>
      {/* Every message the line can show sits here unseen, so the line is always as tall as the longest one and nothing below it moves. */}
      {measures.map((text, index) => <p key={index} className={`${styles.note} ${styles.sizer}`} aria-hidden="true">{text}</p>)}
      <Swap id={problem ? `problem-${problem.text}` : done ? "done" : "note"} reduced={reduced} className={styles.messageLive} live="polite">
        <p id={messageId} className={styles.note} data-tone={problem ? "error" : done ? "success" : undefined} role={problem ? "alert" : undefined}>
          {problem?.text ?? (done
            ? <>{doneText}<span className={styles.srOnly}> The link went to {sentTo}.</span> <button ref={again} type="button" className={styles.textButton} onClick={reset}>Use a different email</button></>
            : note)}
        </p>
      </Swap>
    </div>
  </form>;

  const root = [styles.newsletter, className].filter(Boolean).join(" ");

  if (variant === "card") {
    return <section ref={ref} className={root} data-variant="card" aria-labelledby={`${id}-title`}>
      {/* The shell spans the section so a host layout can set its gutter without reaching into the card. */}
      <div className={styles.cardShell}>
      <div className={styles.card}>
        {publication && <div className={styles.tray}>
          <IssueStack publication={publication} delivered={done} to={sentTo} reduced={reduced} compact />
        </div>}
        <div className={styles.cardBody}>
          <h2 id={`${id}-title`} className={styles.cardTitle}>{title ?? copy.title}</h2>
          <p className={styles.description}>{description ?? copy.description}</p>
          {form}
          {readers && <Readers readers={readers} done={done} reduced={reduced} />}
        </div>
      </div>
      </div>
    </section>;
  }

  return <section ref={ref} className={root} data-variant="inline" data-aside={publication || readers ? "" : undefined} aria-labelledby={`${id}-title`}>
    <div className={styles.inline}>
      <div className={styles.intro}>
        <h2 id={`${id}-title`} className={styles.title}>{title ?? copy.title}</h2>
        <p className={styles.description}>{description ?? copy.description}</p>
        {form}
      </div>
      {(publication || readers) && <div className={styles.aside}>
        {publication && <IssueStack publication={publication} delivered={done} to={sentTo} reduced={reduced} />}
        {readers && <Readers readers={readers} done={done} reduced={reduced} />}
      </div>}
    </div>
  </section>;
});

NewsletterSignup.displayName = "NewsletterSignup";

const variantOptions = [{ value: "inline", label: "Inline" }, { value: "card", label: "Card" }];

/** Preview: both layouts. Subscribing is simulated and nothing is sent; the switch makes the next send fail. */
export function NewsletterSignupBlock() {
  const [variant, setVariant] = useState<NewsletterVariant>("inline");
  const [fail, setFail] = useState(false);
  const switchId = useId();
  const simulate = () => new Promise<void>((resolve, reject) => setTimeout(() => fail ? reject(new Error("Simulated failure")) : resolve(), 1100));
  return <div className={styles.preview}>
    <div className={styles.controls}>
      <SegmentedControl label="Newsletter layout" options={variantOptions} value={variant} onValueChange={next => setVariant(next as NewsletterVariant)} />
      <div className={styles.simulate}>
        <Switch id={switchId} checked={fail} onCheckedChange={setFail} />
        <label htmlFor={switchId}>Fail the next send</label>
      </div>
    </div>
    <div className={styles.frame}>
      <NewsletterSignup key={variant} variant={variant} onSubscribe={simulate} />
    </div>
    <p className={styles.caption}>Subscribing is simulated in this preview. Nothing is sent.</p>
  </div>;
}

export default NewsletterSignupBlock;
```

### `registry/blocks/newsletter-signup/newsletter-signup-data.ts`

```ts
import { avatar, photo, type PersonId, type PhotoId } from "../../lib/media";

/** One story teased in an issue. `image` is a small square-cropped thumbnail. */
export interface NewsletterStory {
  title: string;
  image: string;
  minutes: number;
}

/** One issue of the newsletter, as it appears on the stack. */
export interface NewsletterIssue {
  number: number;
  /** Send date as it should read, such as "Friday, September 25". */
  date: string;
  subject: string;
  /** Up to three stories. */
  stories: NewsletterStory[];
}

/** The publication shown beside the form: the next issue lands on the stack of recent ones when someone subscribes. */
export interface NewsletterPublication {
  name: string;
  /** The next issue. It is addressed to the new reader and joins the front of the stack on success. */
  upcoming: NewsletterIssue;
  /** Recent issues, newest first. The first three make the stack. */
  recent: NewsletterIssue[];
}

const story = (title: string, image: PhotoId, minutes: number): NewsletterStory => ({ title, image: photo(image).src, minutes });

export const newsletterCopy = {
  inline: {
    title: "Five minutes on interface craft, every Friday",
    description: "Three links worth your time and one short essay on the details that make software feel right.",
  },
  card: {
    title: "Get the next issue on Friday",
    description: "Three links and one short essay on interface craft. Five minutes, once a week.",
  },
  privacy: "No tracking pixels. Unsubscribe with one click.",
  privacyLink: { label: "Privacy policy", href: "#privacy" },
};

/** Sample publication. Dates follow a Friday send; images come from `public/media/photos`. */
export const newsletterPublication: NewsletterPublication = {
  name: "Margins",
  upcoming: {
    number: 149,
    date: "Friday, October 2",
    subject: "Designing for the second visit, not the first",
    stories: [
      story("Empty states that invite a first step", "reading-chair", 5),
      story("Texture without noise, from a ceramics studio", "stacked-bowls", 4),
      story("How a concert hall seats two thousand people", "concert-hall", 7),
    ],
  },
  recent: [
    {
      number: 148,
      date: "Friday, September 25",
      subject: "Why the best settings pages feel quiet",
      stories: [
        story("What a sunroom teaches about contrast", "sunroom", 4),
        story("The case for a single accent color", "ceramic-lamp", 3),
        story("Lisbon tram signs and wayfinding at scale", "lisbon-tram", 6),
      ],
    },
    {
      number: 147,
      date: "Friday, September 18",
      subject: "Springs that settle instead of bounce",
      stories: [
        story("Reading the ridge line of an animation curve", "mountain-ridges", 5),
        story("A table lamp and the warmth of dark mode", "table-lamp", 3),
        story("Curves in architecture and in corners", "curved-facade", 4),
      ],
    },
    {
      number: 146,
      date: "Friday, September 11",
      subject: "Copy that sounds like a person",
      stories: [
        story("Error messages that take the blame", "glass-carafe", 4),
        story("Writing buttons as verbs", "home-office", 3),
        story("Tone of voice at the dinner table", "salmon-dinner", 5),
      ],
    },
  ],
};

/** Readers shown under the stack and in the card. */
export const newsletterReaders = {
  count: 12480,
  faces: (["jasmine-brooks", "daniel-kim", "ava-mitchell"] as PersonId[]).map(id => avatar(id)),
};
```

### `lib/media.ts`

```ts
/**
 * Shared demo media. Files live in `public/media/`, credits in `public/media/CREDITS.md`.
 * Portraits are 400 px squares; photos are 1600 px on the long side.
 */

export interface MediaPerson {
  id: string;
  name: string;
  role?: string;
  src: string;
}

export interface MediaPhoto {
  id: string;
  alt: string;
  src: string;
  width: number;
  height: number;
}

export const people = [
  { id: "emma-collins", name: "Emma Collins", role: "Product designer", src: "/media/people/emma-collins.jpg" },
  { id: "marcus-johnson", name: "Marcus Johnson", role: "Frontend engineer", src: "/media/people/marcus-johnson.jpg" },
  { id: "jasmine-brooks", name: "Jasmine Brooks", role: "Design lead", src: "/media/people/jasmine-brooks.jpg" },
  { id: "olivia-bennett", name: "Olivia Bennett", role: "Platform engineer", src: "/media/people/olivia-bennett.jpg" },
  { id: "sofia-ramirez", name: "Sofia Ramirez", role: "Operations lead", src: "/media/people/sofia-ramirez.jpg" },
  { id: "ryan-sullivan", name: "Ryan Sullivan", role: "Account executive", src: "/media/people/ryan-sullivan.jpg" },
  { id: "hannah-walsh", name: "Hannah Walsh", role: "Customer success", src: "/media/people/hannah-walsh.jpg" },
  { id: "chloe-nguyen", name: "Chloe Nguyen", role: "Data analyst", src: "/media/people/chloe-nguyen.jpg" },
  { id: "ava-mitchell", name: "Ava Mitchell", role: "Marketing manager", src: "/media/people/ava-mitchell.jpg" },
  { id: "daniel-kim", name: "Daniel Kim", role: "Backend engineer", src: "/media/people/daniel-kim.jpg" },
  { id: "jordan-reyes", name: "Jordan Reyes", role: "Support specialist", src: "/media/people/jordan-reyes.jpg" },
  { id: "mateo-alvarez", name: "Mateo Alvarez", role: "Mobile engineer", src: "/media/people/mateo-alvarez.jpg" },
  { id: "tyler-hayes", name: "Tyler Hayes", role: "Sales lead", src: "/media/people/tyler-hayes.jpg" },
  { id: "andre-williams", name: "Andre Williams", role: "Finance partner", src: "/media/people/andre-williams.jpg" },
  { id: "nathan-cole", name: "Nathan Cole", role: "Engineering manager", src: "/media/people/nathan-cole.jpg" },
  { id: "diane-foster", name: "Diane Foster", role: "Chief operating officer", src: "/media/people/diane-foster.jpg" },
] as const satisfies readonly MediaPerson[];

export const photos = [
  { id: "lounge-chair", alt: "A woven oak lounge chair with a sheepskin and linen cushion on a concrete floor", src: "/media/photos/lounge-chair.jpg", width: 1280, height: 1600 },
  { id: "table-lamp", alt: "A white mushroom table lamp glowing beside books and a small vase", src: "/media/photos/table-lamp.jpg", width: 1600, height: 900 },
  { id: "linen-throw", alt: "Folded natural linen throws with fringed edges in soft window light", src: "/media/photos/linen-throw.jpg", width: 1067, height: 1600 },
  { id: "glass-carafe", alt: "A hand pouring water from a ribbed glass carafe into tumblers", src: "/media/photos/glass-carafe.jpg", width: 1280, height: 1600 },
  { id: "stoneware-cups", alt: "Two speckled stoneware cups with a lid on a pale table", src: "/media/photos/stoneware-cups.jpg", width: 1067, height: 1600 },
  { id: "stacked-bowls", alt: "Two stacked speckled ceramic bowls against a dark wall", src: "/media/photos/stacked-bowls.jpg", width: 1600, height: 1067 },
  { id: "ceramic-lamp", alt: "A sculptural ceramic lamp with a linen shade on a walnut sideboard", src: "/media/photos/ceramic-lamp.jpg", width: 1067, height: 1600 },
  { id: "living-room", alt: "A bright living room with timber beams, arched windows, and cream sofas", src: "/media/photos/living-room.jpg", width: 1200, height: 1600 },
  { id: "sunroom", alt: "A sunroom with a round dining table, plants, and windows on three sides", src: "/media/photos/sunroom.jpg", width: 1600, height: 1067 },
  { id: "home-office", alt: "A home office with a wooden desk and deep green walls", src: "/media/photos/home-office.jpg", width: 1600, height: 1200 },
  { id: "reading-chair", alt: "A grey armchair and ottoman with a knit throw in a dark green room", src: "/media/photos/reading-chair.jpg", width: 1600, height: 900 },
  { id: "bedroom", alt: "A made bed with striped linen pillows against an oak headboard", src: "/media/photos/bedroom.jpg", width: 1067, height: 1600 },
  { id: "restaurant", alt: "A warm restaurant dining room with woven pendant lights and a tree", src: "/media/photos/restaurant.jpg", width: 1067, height: 1600 },
  { id: "wine-bar", alt: "A glass carafe of red wine on a bar table in low evening light", src: "/media/photos/wine-bar.jpg", width: 1600, height: 1067 },
  { id: "concert-hall", alt: "Curved stainless steel panels of the Walt Disney Concert Hall against a blue sky", src: "/media/photos/concert-hall.jpg", width: 1600, height: 1143 },
  { id: "curved-facade", alt: "A white tiled building facade with curved balconies", src: "/media/photos/curved-facade.jpg", width: 1600, height: 1067 },
  { id: "pool-house", alt: "A modern glass house beside a long pool under a clear sky", src: "/media/photos/pool-house.jpg", width: 1600, height: 900 },
  { id: "terracotta-waves", alt: "Wavy terracotta walls rising toward a blue sky", src: "/media/photos/terracotta-waves.jpg", width: 1067, height: 1600 },
  { id: "mountain-ridges", alt: "Layered mountain ridges under a warm evening sky", src: "/media/photos/mountain-ridges.jpg", width: 1600, height: 1068 },
  { id: "alpine-lake", alt: "A calm alpine lake reflecting a rocky peak at golden hour", src: "/media/photos/alpine-lake.jpg", width: 1067, height: 1600 },
  { id: "coastline", alt: "A long coastline with waves rolling onto a beach below green cliffs", src: "/media/photos/coastline.jpg", width: 1200, height: 1600 },
  { id: "sea-at-dusk", alt: "A calm sea at dusk with a low island on the horizon", src: "/media/photos/sea-at-dusk.jpg", width: 1067, height: 1600 },
  { id: "lisbon-tram", alt: "A yellow tram on a street lined with historic buildings in Lisbon", src: "/media/photos/lisbon-tram.jpg", width: 1600, height: 1064 },
  { id: "lisbon-bridge", alt: "The 25 de Abril Bridge crossing the Tagus in Lisbon", src: "/media/photos/lisbon-bridge.jpg", width: 1600, height: 1166 },
  { id: "lisbon-rooftops", alt: "Terracotta rooftops of Lisbon running down to the river", src: "/media/photos/lisbon-rooftops.jpg", width: 1280, height: 1600 },
  { id: "salmon-dinner", alt: "Seared salmon with a bright herb salsa and a glass of red wine", src: "/media/photos/salmon-dinner.jpg", width: 1067, height: 1600 },
  { id: "chef-plating", alt: "A chef spooning sauce onto a plated dish in a dark kitchen", src: "/media/photos/chef-plating.jpg", width: 1600, height: 1600 },
] as const satisfies readonly MediaPhoto[];

export type PersonId = (typeof people)[number]["id"];
export type PhotoId = (typeof photos)[number]["id"];

/** Looks up a person by id. */
export function person(id: PersonId): MediaPerson {
  return people.find(entry => entry.id === id)!;
}

/** Looks up a photo by id. */
export function photo(id: PhotoId): MediaPhoto {
  return photos.find(entry => entry.id === id)!;
}

/** Square 400 px avatar path for a person id, for `src` props. */
export const avatar = (id: PersonId) => person(id).src;

/** 800 × 1000 portrait crop of the same photo, for image-led layouts. */
export const portrait = (id: PersonId) => `/media/people/portrait/${id}.jpg`;

/** The first `count` people, for avatar stacks and lists. */
export const peopleSample = (count: number) => people.slice(0, count);
```

### `registry/blocks/newsletter-signup/newsletter-signup.module.css`

```css
.newsletter {
  --paper: var(--surface-raised);
  --paper-back: color-mix(in oklab, var(--surface-raised), var(--surface-muted) 55%);
  --paper-far: var(--surface-muted);
  --paper-shadow: 0 1px 1px oklch(0% 0 0 / .03), 0 10px 28px -6px oklch(0% 0 0 / .09);
  container: newsletter / inline-size;
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-body);
  letter-spacing: var(--tracking-body);
  text-align: start;
}
:global(:root[data-theme="dark"]) .newsletter {
  --paper: oklch(24.5% 0 0);
  --paper-back: oklch(22.5% 0 0);
  --paper-far: oklch(21% 0 0);
  --paper-shadow: 0 1px 1px oklch(0% 0 0 / .2), 0 14px 32px -6px oklch(0% 0 0 / .45);
}
.newsletter *, .newsletter *::before, .newsletter *::after { box-sizing: border-box; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Inline: copy and form on the left, the issue stack on the right. */
.inline { display: grid; max-width: 1120px; grid-template-columns: minmax(0, 1fr); gap: var(--space-12) var(--space-16); margin: 0 auto; padding: var(--space-24) var(--space-8); }
.newsletter[data-aside] .inline { grid-template-columns: minmax(0, 1fr) minmax(0, 440px); align-items: center; }
/* Without the stack or readers the section is the copy and form alone. */
.newsletter[data-variant="inline"]:not([data-aside]) .inline { max-width: 720px; }
.intro { display: grid; max-width: 540px; }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(2rem, 1.1rem + 3.2cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { margin: var(--space-4) 0 0; color: var(--text-secondary); font-size: var(--text-lg); line-height: var(--leading-body); text-wrap: pretty; }
.intro .form { margin-top: var(--space-10); }
.aside { display: grid; justify-items: center; gap: var(--space-6); min-width: 0; }

/* The stack: every issue shares one grid cell, so the stack is as tall as one issue and nothing shifts when they trade places. */
.stack { display: grid; width: 100%; max-width: 440px; padding-top: calc(12px * 2); }
.issue {
  display: grid;
  grid-area: 1 / 1;
  align-content: start;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 22px;
  padding: 22px 22px 18px;
  background: var(--paper);
  box-shadow: var(--paper-shadow);
  transform-origin: 50% 0;
  transition: background-color var(--duration-considered) var(--ease-standard);
}
.issueSizer { visibility: hidden; box-shadow: none; }
.issue[data-depth="1"] { background: var(--paper-back); box-shadow: none; }
.issue[data-depth="2"] { background: var(--paper-far); box-shadow: none; }
.issueHead { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.masthead { font-family: var(--font-display); font-size: var(--text-xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }
.issueNumber, .issueMeta { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
.issueMeta { display: flex; min-width: 0; gap: 6px; margin: 2px 0 0; white-space: nowrap; }
.issueMeta > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.recipient { color: var(--text-secondary); }
.recipientEmail { color: var(--foreground); }
.subject { min-height: calc(2em * var(--leading-display)); margin: var(--space-5) 0 var(--space-4); border-top: 1px solid var(--border); padding-top: var(--space-5); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; box-sizing: content-box; }
.stack[data-compact] .subject { font-size: var(--text-xl); }
.stories { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.story { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 44px; }
.thumb { display: block; width: 44px; height: 44px; border-radius: 10px; object-fit: cover; background: var(--surface-muted); }
.storyTitle { display: -webkit-box; overflow: hidden; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); -webkit-box-orient: vertical; -webkit-line-clamp: 2; text-wrap: pretty; }
.minutes { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; white-space: nowrap; }

/* Readers: faces and a count that ticks up by one. */
.readers { display: flex; align-items: center; gap: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.faces { display: flex; flex: none; padding-left: 6px; }
.faces img { display: block; width: 28px; height: 28px; margin-left: -6px; border: 2px solid var(--background); border-radius: 50%; object-fit: cover; }
.card .faces img { border-color: var(--surface); }
.readerLine { min-width: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
.count { display: inline-block; color: var(--foreground); }
.count > span { color: inherit; font-family: inherit; font-size: inherit; font-weight: 500; letter-spacing: inherit; line-height: inherit; vertical-align: bottom; }
.you { display: inline-block; overflow: hidden; color: var(--text-secondary); vertical-align: bottom; white-space: nowrap; }

/* Card: a self-contained card whose top is a tray holding the stack, cropped where the card body begins. */
.newsletter[data-variant="card"] { padding: var(--space-20) var(--space-6); }
.cardShell { display: grid; width: 100%; place-items: center; }
.card { display: grid; width: min(100%, 460px); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); box-shadow: var(--shadow-resting); }
.tray { display: grid; height: 248px; grid-template-rows: minmax(0, 1fr); overflow: hidden; justify-items: center; border-bottom: 1px solid var(--border); padding: 20px 32px 0; background: var(--surface-muted); }
.tray .stack { height: 100%; -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent); }
.tray .issue { box-shadow: none; }
.cardBody { display: grid; padding: var(--space-8); }
.cardTitle { margin: 0; font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.card .description { margin-top: var(--space-2); font-size: var(--text-base); }
.card .form { margin-top: var(--space-6); }
.card .readers { margin-top: var(--space-5); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
:global(:root[data-theme="dark"]) .tray { background: var(--background); }

/* The pill: one field that holds the input and the button. */
.form { container: signup / inline-size; display: grid; gap: var(--space-3); min-width: 0; }
.pill { position: relative; display: flex; height: 58px; align-items: center; gap: 6px; border: 1px solid var(--border-strong); border-radius: 999px; padding: 5px 5px 5px 22px; background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .pill:not([data-done]):hover { border-color: color-mix(in oklab, var(--foreground) 32%, var(--border-strong)); } }
.pill:focus-within { border-color: var(--foreground); }
.pill.pill[data-invalid] { border-color: var(--danger); }
.pill.pill[data-done] { border-color: color-mix(in oklab, var(--success) 45%, var(--border)); }
.input { width: 100%; min-width: 0; height: 100%; flex: 1; border: 0; padding: 0; background: none; color: var(--foreground); font: inherit; font-size: var(--text-base); letter-spacing: inherit; outline: none; transition: color var(--duration-standard) var(--ease-standard); }
.input::placeholder { color: var(--text-muted); }
.pill[data-done] .input { color: var(--text-secondary); }

.submit { position: relative; display: inline-flex; height: 100%; min-height: 44px; flex: none; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 0 20px; background: var(--foreground); color: var(--background); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; white-space: nowrap; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .submit[data-state="idle"]:hover, .submit[data-state="retry"]:hover { background: color-mix(in oklab, var(--foreground) 84%, var(--background)); } }
.submit[data-state="sending"] { cursor: progress; }
.submit[data-state="done"] { background: color-mix(in oklab, var(--success) 13%, var(--surface)); color: var(--success); cursor: default; }
.labels { display: inline-grid; place-items: center; }
.sizer, .label { display: inline-flex; grid-area: 1 / 1; align-items: center; gap: 8px; }
.sizer { visibility: hidden; }
.note.sizer { display: block; }
.spinner { width: 14px; height: 14px; border: 2px solid color-mix(in oklab, currentColor 30%, transparent); border-top-color: currentColor; border-radius: 50%; animation: spin 700ms linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.check { display: inline-flex; width: 16px; height: 16px; }
.check svg { width: 16px; height: 16px; }

/* Two lines are reserved so the note, an error, and the confirmation never move what sits below. */
.message { display: grid; }
.message > * { grid-area: 1 / 1; min-width: 0; }
.messageLive { position: relative; }
.note { margin: 0; padding-left: 22px; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }
.note[data-tone="error"] { color: var(--danger); }
.note[data-tone="success"] { color: var(--text-secondary); }
.link, .textButton { color: var(--foreground); text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; transition: text-decoration-color var(--duration-fast) var(--ease-standard); }
.textButton { border: 0; padding: 0; background: none; font: inherit; cursor: pointer; }
@media (hover: hover) and (pointer: fine) { .link:hover, .textButton:hover { text-decoration-color: currentColor; } }

@container newsletter (max-width: 860px) {
  .newsletter[data-variant="inline"] .inline { grid-template-columns: minmax(0, 1fr); gap: var(--space-12); padding: var(--space-16) var(--space-8); }
  .intro { max-width: 600px; }
}
@container newsletter (max-width: 520px) {
  .newsletter[data-variant="inline"] .inline { padding: var(--space-12) var(--space-4); gap: var(--space-10); }
  .description { font-size: var(--text-base); }
  .intro .form { margin-top: var(--space-8); }
  .issue { border-radius: 20px; padding: 18px 18px 16px; }
  .subject, .stack[data-compact] .subject { font-size: var(--text-xl); }
  .newsletter[data-variant="card"] { padding: var(--space-10) var(--space-4); }
  .card { border-radius: 28px; }
  .tray { height: 232px; padding: 16px 20px 0; }
  .cardBody { padding: var(--space-6); }
  .note { padding-left: 4px; }
}
@container newsletter (max-width: 400px) {
  .minutes { display: none; }
  /* The recipient matters more than the date once the issue is addressed. */
  .issueMeta:has(.recipient) > :not(.recipient) { display: none; }
  .story { grid-template-columns: 44px minmax(0, 1fr); }
  .faces img { width: 24px; height: 24px; }
  .readers { gap: var(--space-2); }
}
@container newsletter (max-width: 360px) {
  .readers { align-items: flex-start; }
  .readerLine { min-height: calc(2 * 1.4em); white-space: normal; }
}
/* On narrow phones the field and the button separate and stack, both full width. */
@container signup (max-width: 300px) {
  .pill { height: auto; flex-direction: column; align-items: stretch; gap: 8px; border: 0; padding: 0; background: none; }
  .input { height: 52px; flex: none; border: 1px solid var(--border-strong); border-radius: 999px; padding: 0 20px; background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
  .input:focus { border-color: var(--foreground); }
  .pill.pill[data-invalid] .input { border-color: var(--danger); }
  .pill.pill[data-done] .input { border-color: color-mix(in oklab, var(--success) 45%, var(--border)); }
  .submit { height: 48px; }
  .note { padding-left: 4px; }
}

/* Preview chrome. */
.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--space-3) var(--space-6); }
.simulate { display: inline-flex; align-items: center; gap: 10px; color: var(--text-secondary); font-size: var(--text-sm); }
.simulate label { cursor: pointer; }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }
.caption { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .pill, .submit, .input, .issue { transition: none; }
  .spinner { animation-duration: 1.6s; }
}
```
