Newsletter
A sign-up for updates: one field, one button and what the reader will receive.
Inline newsletter sign-up
newsletter-01The letter's name, what an issue does and when it arrives, one field and one button on a line, and five readers' faces beside how many read it.
import * as React from "react"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { InlineSignup } from "./newsletter-01.client"
import { EYEBROW, HEADLINE, LEDE, READERS, SIGNUP } from "./newsletter-01.data"
/**
* The newsletter on its own, centred: its name, what an issue does and when
* it arrives, one field and one button on a line, and the people who read it
* already — five faces beside how many.
*
* The faces are decoration beside the count that says it in words, so they
* are hidden from assistive technology. The form is the one island; this file
* is a server component, and every word, the refusals and the confirmation
* included, is in `./newsletter-01.data`.
*/
export function Newsletter01() {
const titleId = React.useId()
return (
<section
data-section="newsletter-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-2xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-balance text-muted-foreground">{LEDE}</p>
<div className="mt-8 w-full max-w-md">
<InlineSignup copy={SIGNUP} />
</div>
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row">
<div aria-hidden="true">
<AvatarGroup users={READERS.people.map((reader) => ({ name: reader.name, src: reader.face }))} />
</div>
<p className="text-sm text-muted-foreground">{READERS.caption}</p>
</div>
</div>
</div>
</section>
)
}
export default Newsletter01"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { SignupCopy } from "./newsletter-01.data"
/**
* The sign-up on one line: a labelled field and its button side by side from
* `sm`, the note that describes the field under them. The browser's own
* validation is off so the form refuses in its own words — an empty field
* and a malformed address each get a message tied to the field, which is
* marked invalid and takes focus back. An accepted address clears the field
* and is confirmed in the status line, which is always mounted so the
* confirmation is announced as it arrives. The refusal and the confirmation
* never share a slot.
*
* It keeps no list: wire the accepted address to your mailing provider where
* `setSubscribed` is called.
*/
export function InlineSignup({ copy }: { copy: SignupCopy }) {
const inputId = React.useId()
const errorId = React.useId()
const noteId = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [error, setError] = React.useState<string | null>(null)
const [subscribed, setSubscribed] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
const problem = email === "" ? copy.empty : isEmail(email) ? null : copy.invalid
setError(problem)
if (problem) {
setSubscribed(null)
inputRef.current?.focus()
return
}
setSubscribed(email)
event.currentTarget.reset()
}
return (
<form noValidate onSubmit={submit} className="flex w-full flex-col gap-2 text-left">
<Label htmlFor={inputId}>{copy.label}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
ref={inputRef}
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${errorId} ${noteId}` : noteId}
className="h-10 sm:flex-1"
/>
<Button type="submit" size="lg" className="h-10 px-5">
{copy.submit}
</Button>
</div>
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
<p id={noteId} className="text-xs text-muted-foreground">
{copy.note}
</p>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{subscribed ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{copy.subscribed.replace("{email}", subscribed)}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `newsletter-01` sets: what the letter is and when it arrives, the
* form's label, refusals and confirmation, and who reads it already — each
* reader drawn with the kit's face for their name, from the `avatars` item
* rather than the store. Replace the values with your own list's.
*/
import { avatarFor } from "@/lib/avatars"
/** What the sign-up says, handed to its island as plain props. */
export type SignupCopy = {
label: string
placeholder: string
submit: string
/** The line under the field, which describes it: when the letter arrives and how to leave. */
note: string
/** The refusal for a field left empty. */
empty: string
/** The refusal for something that is not an address. */
invalid: string
/** The confirmation; `{email}` is replaced with the address that subscribed. */
subscribed: string
}
export type Reader = { name: string; face: string }
export const EYEBROW = "The Weekly Number"
export const HEADLINE = "One metric, taken apart, every Tuesday"
export const LEDE =
"Each issue takes a figure most teams report and few can explain — net revenue retention, payback, first-reply time — and shows how to read it, with the chart and the query behind it."
export const SIGNUP: SignupCopy = {
label: "Work email",
placeholder: "name@example.com",
submit: "Subscribe",
note: "Tuesdays at 07:00 UTC. One click to unsubscribe.",
empty: "Enter your email address to subscribe.",
invalid: "Enter an email address in the format name@example.com.",
subscribed: "You’re subscribed. The next issue reaches {email} on Tuesday.",
}
/** Five of the people who read it, and how many do. */
export const READERS: { caption: string; people: readonly Reader[] } = {
caption: "Read by 6,400 people in finance and operations",
people: ["Maren Kovač", "Aisha Adeyemi", "Daniel Osei", "Lucía Fernández", "Kenji Watanabe"].map((name) => ({
name,
face: avatarFor(name),
})),
}npx shadcn@latest add @vibra/newsletter-01Newsletter card with what each issue carries
newsletter-02What the letter is and the three things every issue carries, beside a framed card holding the form, with who writes it in the card's footer.
import * as React from "react"
import { ChartLineIcon, ClockIcon, CodeIcon, type LucideIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { CardSignup } from "./newsletter-02.client"
import { AUTHOR, CARD, CONTENTS, EYEBROW, HEADLINE, LEDE, SIGNUP, type ContentIcon } from "./newsletter-02.data"
const ICONS: Record<ContentIcon, LucideIcon> = {
metric: ChartLineIcon,
query: CodeIcon,
time: ClockIcon,
}
/**
* The newsletter as a pitch and a card: what the letter is and the three
* things every issue carries on one side, and on the other the kit's framed
* Card holding the form, with who writes it in the card's footer strip.
*
* The card's title names the form, so the sign-up is a landmark of its own;
* the author's face is decoration beside her name. The form is the one
* island; this file is a server component and every word is in
* `./newsletter-02.data`.
*/
export function Newsletter02() {
const titleId = React.useId()
const cardTitleId = React.useId()
return (
<section
data-section="newsletter-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-7">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 max-w-2xl text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<h3 className="mt-10 text-sm font-semibold">{CONTENTS.heading}</h3>
<ul className="mt-4 grid gap-6 sm:grid-cols-3">
{CONTENTS.items.map((item) => {
const Icon = ICONS[item.icon]
return (
<li key={item.title} className="flex gap-3 sm:flex-col">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-surface text-foreground ring-1 ring-border">
<Icon aria-hidden="true" className="size-4" />
</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-sm text-pretty text-muted-foreground">{item.text}</p>
</div>
</li>
)
})}
</ul>
</div>
<Card className="lg:col-span-5">
<CardHeader>
<CardTitle>
<h3 id={cardTitleId}>{CARD.title}</h3>
</CardTitle>
<CardDescription>{CARD.description}</CardDescription>
</CardHeader>
<CardContent className="pb-2">
<CardSignup copy={SIGNUP} labelledBy={cardTitleId} />
</CardContent>
<CardFooter className="gap-2.5">
<Avatar size="sm">
<AvatarImage src={AUTHOR.face} alt="" />
<AvatarFallback>{getInitials(AUTHOR.name)}</AvatarFallback>
</Avatar>
<p className="text-xs text-muted-foreground">
{AUTHOR.lead} <span className="font-medium text-foreground">{AUTHOR.name}</span>, {AUTHOR.role}
</p>
</CardFooter>
</Card>
</div>
</section>
)
}
export default Newsletter02"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { SignupCopy } from "./newsletter-02.data"
/**
* The card's sign-up, stacked: the labelled field, the note that describes
* it, and a button as wide as the card. A form named by the card's title, so
* it is a landmark of its own. The browser's own validation is off so the
* form refuses in its own words — an empty field and a malformed address each
* get a message tied to the field, which is marked invalid and takes focus
* back — and an accepted address clears the field and is confirmed in the
* status line, always mounted so it is announced as it arrives.
*
* It keeps no list: wire the accepted address to your mailing provider where
* `setSubscribed` is called.
*/
export function CardSignup({ copy, labelledBy }: { copy: SignupCopy; labelledBy: string }) {
const inputId = React.useId()
const errorId = React.useId()
const noteId = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [error, setError] = React.useState<string | null>(null)
const [subscribed, setSubscribed] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
const problem = email === "" ? copy.empty : isEmail(email) ? null : copy.invalid
setError(problem)
if (problem) {
setSubscribed(null)
inputRef.current?.focus()
return
}
setSubscribed(email)
event.currentTarget.reset()
}
return (
<form noValidate onSubmit={submit} aria-labelledby={labelledBy} className="flex flex-col gap-2">
<Label htmlFor={inputId}>{copy.label}</Label>
<Input
ref={inputRef}
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${errorId} ${noteId}` : noteId}
className="h-10"
/>
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
<p id={noteId} className="text-xs text-muted-foreground">
{copy.note}
</p>
<Button type="submit" size="lg" className="mt-2 h-10 w-full">
{copy.submit}
</Button>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{subscribed ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{copy.subscribed.replace("{email}", subscribed)}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `newsletter-02` sets: what the letter is, the three things every
* issue carries, the card's form — its label, note, refusals and confirmation
* — and who writes it, drawn with the kit's face for her name from the
* `avatars` item rather than the store. Replace the values with your own.
*/
import { avatarFor } from "@/lib/avatars"
export type ContentIcon = "metric" | "query" | "time"
export type IssueContent = { icon: ContentIcon; title: string; text: string }
/** What the sign-up says, handed to its island as plain props. */
export type SignupCopy = {
label: string
placeholder: string
submit: string
note: string
empty: string
invalid: string
/** `{email}` is replaced with the address that subscribed. */
subscribed: string
}
export const EYEBROW = "Newsletter"
export const HEADLINE = "Get the Weekly Number in your inbox"
export const LEDE =
"A short letter from Northwind’s analytics team: one figure a week, what it measures, what moves it and how your own compares."
export const CONTENTS: { heading: string; items: readonly IssueContent[] } = {
heading: "In every issue",
items: [
{ icon: "metric", title: "One metric, explained", text: "What it measures, what moves it and what a good number looks like." },
{ icon: "query", title: "The query behind it", text: "SQL you can paste into your own warehouse and run as it is." },
{ icon: "time", title: "Five minutes to read", text: "Plain words, one chart and nothing to sell." },
],
}
export const CARD = { title: "Subscribe", description: "Free, every Tuesday at 07:00 UTC" }
export const SIGNUP: SignupCopy = {
label: "Work email",
placeholder: "name@example.com",
submit: "Subscribe",
note: "No tracking pixels. One click to unsubscribe.",
empty: "Enter your email address to subscribe.",
invalid: "Enter an email address in the format name@example.com.",
subscribed: "Subscribed. Issue 113 reaches {email} on Tuesday.",
}
export const AUTHOR = {
lead: "Written by",
name: "Ines Moreau",
role: "head of analytics at Northwind",
face: avatarFor("Ines Moreau"),
}npx shadcn@latest add @vibra/newsletter-02Newsletter with its recent issues
newsletter-03The sign-up beside the last four issues, newest first, each with its number, the day it went out, how long it takes to read, its title as a link and what it found.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { IssueSignup } from "./newsletter-03.client"
import { ARCHIVE, EYEBROW, HEADLINE, ISSUE_NUMBER, ISSUES, ISSUES_LABEL, LEDE, READ_TIME, SIGNUP } from "./newsletter-03.data"
/** An issue's day, in UTC: the day it went out wherever the reader is. */
const SENT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/**
* The sign-up beside the proof of it: the last four issues, newest first,
* each with its number, the day it went out, how long it takes to read, its
* title as a link and one line on what it found — so the reader subscribes to
* something they have seen rather than a promise.
*
* The issues are an ordered list named by its label, each title a heading
* and a link that covers its row, each day a `<time>`. The form is the one
* island; this file is a server component and every word is in
* `./newsletter-03.data`.
*/
export function Newsletter03() {
const titleId = React.useId()
const issuesId = React.useId()
return (
<section
data-section="newsletter-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-12 lg:gap-16">
<div className="flex flex-col items-start lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8 w-full">
<IssueSignup copy={SIGNUP} />
</div>
<Link
href={ARCHIVE.href}
className="group/archive mt-2 inline-flex items-center gap-1 rounded-sm text-sm font-medium underline-offset-4 focus-ring hover:underline"
>
{ARCHIVE.label}
<ArrowRightIcon
aria-hidden="true"
className="size-3.5 transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/archive:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
<div className="lg:col-span-7">
<p id={issuesId} className="type-eyebrow">
{ISSUES_LABEL}
</p>
<ol aria-labelledby={issuesId} className="mt-3 flex flex-col border-t border-border">
{ISSUES.map((issue) => (
<li key={issue.number} className="relative flex flex-col gap-1.5 border-b border-border py-5">
<p className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground tabular-nums">
<span>{ISSUE_NUMBER.replace("{number}", String(issue.number))}</span>{" "}
<span aria-hidden="true">·</span>{" "}
<time dateTime={issue.sent}>{SENT.format(new Date(`${issue.sent}T00:00:00Z`))}</time>{" "}
<span aria-hidden="true">·</span>{" "}
<span>{READ_TIME.replace("{minutes}", String(issue.minutes))}</span>
</p>
<h3 className="text-base font-semibold text-pretty">
<Link
href={issue.href}
className="rounded-sm underline-offset-4 focus-ring after:absolute after:inset-0 hover:underline"
>
{issue.title}
</Link>
</h3>
<p className="text-sm text-pretty text-muted-foreground">{issue.summary}</p>
</li>
))}
</ol>
</div>
</div>
</section>
)
}
export default Newsletter03"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { SignupCopy } from "./newsletter-03.data"
/**
* The sign-up on one line: a labelled field and its button side by side from
* `sm`, the note that describes the field under them. The browser's own
* validation is off so the form refuses in its own words — an empty field
* and a malformed address each get a message tied to the field, which is
* marked invalid and takes focus back. An accepted address clears the field
* and is confirmed in the status line, which is always mounted so the
* confirmation is announced as it arrives. The refusal and the confirmation
* never share a slot.
*
* It keeps no list: wire the accepted address to your mailing provider where
* `setSubscribed` is called.
*/
export function IssueSignup({ copy }: { copy: SignupCopy }) {
const inputId = React.useId()
const errorId = React.useId()
const noteId = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [error, setError] = React.useState<string | null>(null)
const [subscribed, setSubscribed] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
const problem = email === "" ? copy.empty : isEmail(email) ? null : copy.invalid
setError(problem)
if (problem) {
setSubscribed(null)
inputRef.current?.focus()
return
}
setSubscribed(email)
event.currentTarget.reset()
}
return (
<form noValidate onSubmit={submit} className="flex w-full flex-col gap-2 text-left">
<Label htmlFor={inputId}>{copy.label}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
ref={inputRef}
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${errorId} ${noteId}` : noteId}
className="h-10 sm:flex-1"
/>
<Button type="submit" size="lg" className="h-10 px-5">
{copy.submit}
</Button>
</div>
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
<p id={noteId} className="text-xs text-muted-foreground">
{copy.note}
</p>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{subscribed ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{copy.subscribed.replace("{email}", subscribed)}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `newsletter-03` sets: the letter, its sign-up — label, refusals
* and confirmation — and the four issues before the next one, each with its
* number, the day it went out, its title, one line on what it found and how
* long it takes to read. The dates are UTC days, the Tuesdays before the
* sample world's `REFERENCE_DATE` (the section's test holds them to it), so
* the list never claims an issue from the future. Replace them with your own
* archive.
*/
export type Issue = {
number: number
/** A UTC day, `yyyy-mm-dd`. */
sent: string
title: string
summary: string
minutes: number
href: string
}
/** What the sign-up says, handed to its island as plain props. */
export type SignupCopy = {
label: string
placeholder: string
submit: string
note: string
empty: string
invalid: string
/** `{email}` is replaced with the address that subscribed. */
subscribed: string
}
export const EYEBROW = "The Weekly Number"
export const HEADLINE = "Read the last four issues before you subscribe"
export const LEDE =
"Every Tuesday, one figure your team already reports, taken apart: what moves it, how to read it and the query behind the chart."
export const SIGNUP: SignupCopy = {
label: "Work email",
placeholder: "name@example.com",
submit: "Subscribe",
note: "Free. One click to unsubscribe.",
empty: "Enter your email address to subscribe.",
invalid: "Enter an email address in the format name@example.com.",
subscribed: "Subscribed. Issue 113 reaches {email} on Tuesday.",
}
export const ARCHIVE = { label: "Browse all 112 issues", href: "/newsletter" }
export const ISSUES_LABEL = "Recent issues"
/** How an issue is numbered, and how its length is read out: "Issue 112", "5 min read". */
export const ISSUE_NUMBER = "Issue {number}"
export const READ_TIME = "{minutes} min read"
export const ISSUES: readonly Issue[] = [
{
number: 112,
sent: "2026-09-01",
title: "Why net revenue retention dipped in August",
summary: "Two large accounts moved to a smaller plan on the same day. How to see that coming three weeks sooner.",
minutes: 5,
href: "/newsletter/112",
},
{
number: 111,
sent: "2026-08-25",
title: "Payback period, without the spreadsheet",
summary: "Payback divides one average by another, and doing it in the wrong order adds four months.",
minutes: 6,
href: "/newsletter/111",
},
{
number: 110,
sent: "2026-08-18",
title: "First-reply time is not one number",
summary: "The median hides the tickets that wait overnight. Split it by hour and the queue says when to staff it.",
minutes: 4,
href: "/newsletter/110",
},
{
number: 109,
sent: "2026-08-11",
title: "Seats bought against seats used",
summary: "Accounts using fewer than half their seats leave at three times the rate. The query that finds them.",
minutes: 5,
href: "/newsletter/109",
},
]npx shadcn@latest add @vibra/newsletter-03Newsletter with a choice of letters
newsletter-04Four letters as checkbox cards, each with how often it arrives and what it carries, over the one field that subscribes to the ones chosen.
import * as React from "react"
import { TopicSignup } from "./newsletter-04.client"
import { EYEBROW, FORM, HEADLINE, LEDE, LETTERS } from "./newsletter-04.data"
/**
* A sign-up that asks what to send before it asks where: the four letters a
* company writes, each a card with how often it arrives and what it carries,
* chosen or not, over the one field that subscribes to them.
*
* The letters are a fieldset of checkbox cards — the kit's SelectableCard,
* the chosen ones on the accent's tint. The form is the one island; this
* file is a server component and every word, the refusals and the
* confirmation included, is in `./newsletter-04.data`.
*/
export function Newsletter04() {
const titleId = React.useId()
return (
<section
data-section="newsletter-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-2xl text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-balance text-muted-foreground">{LEDE}</p>
</div>
<div className="mx-auto mt-10 max-w-3xl frame p-5 sm:p-8">
<TopicSignup letters={LETTERS} copy={FORM} />
</div>
</div>
</section>
)
}
export default Newsletter04"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { SelectableCard } from "@/components/ui/selectable-card"
import type { Letter, TopicFormCopy } from "./newsletter-04.data"
/** "A", "A and B", "A, B and C": the chosen letters as the confirmation says them. */
const AS_SENTENCE = new Intl.ListFormat("en", { style: "long", type: "conjunction" })
/**
* One letter as a card-sized checkbox: named by its title alone, described by
* how often it arrives and what it carries — and by the refusal, when there is
* one — so a screen reader hears the choice before the detail.
*/
function LetterCard({
letter,
chosen,
invalid,
errorId,
onChange,
}: {
letter: Letter
chosen: boolean
invalid: boolean
errorId: string
onChange: (chosen: boolean) => void
}) {
const titleId = React.useId()
const cadenceId = React.useId()
const descriptionId = React.useId()
return (
<SelectableCard
indicator="checkbox"
selected={chosen}
onSelectedChange={onChange}
aria-labelledby={titleId}
aria-describedby={[cadenceId, descriptionId, invalid ? errorId : null].filter(Boolean).join(" ")}
aria-invalid={invalid || undefined}
title={<span id={titleId}>{letter.title}</span>}
badge={
<span id={cadenceId} className="shrink-0 text-xs text-muted-foreground">
{letter.cadence}
</span>
}
description={<span id={descriptionId}>{letter.description}</span>}
className="h-full"
/>
)
}
/**
* The letters as checkbox cards — the chosen ones on the accent's tint, with
* a tick — over one field and its button. The form refuses in its own words:
* no letter chosen and an empty or malformed address each get a message tied
* to what was refused, which is marked invalid, and focus goes to the first
* thing refused. An accepted address clears the field, keeps the choices and
* is confirmed in the status line — always mounted, so it is announced —
* naming every letter chosen.
*
* It keeps no list: wire the address and the letters to your mailing provider
* where `setSubscribed` is called.
*/
export function TopicSignup({ letters, copy }: { letters: readonly Letter[]; copy: TopicFormCopy }) {
const inputId = React.useId()
const noteId = React.useId()
const topicsErrorId = React.useId()
const emailErrorId = React.useId()
const [chosen, setChosen] = React.useState(() => new Set(letters.filter((letter) => letter.chosen).map((letter) => letter.id)))
const [errors, setErrors] = React.useState<{ topics: string | null; email: string | null }>({ topics: null, email: null })
const [subscribed, setSubscribed] = React.useState<string | null>(null)
function choose(id: string, on: boolean) {
setChosen((current) => {
const next = new Set(current)
if (on) next.add(id)
else next.delete(id)
return next
})
// A letter chosen answers the refusal that asked for one.
if (on) setErrors((current) => ({ ...current, topics: null }))
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const email = String(new FormData(form).get("email") ?? "").trim()
const next = {
topics: chosen.size === 0 ? copy.noTopic : null,
email: email === "" ? copy.empty : isEmail(email) ? null : copy.invalid,
}
setErrors(next)
if (next.topics || next.email) {
setSubscribed(null)
const target = next.topics ? form.querySelector("[role=checkbox]") : form.elements.namedItem("email")
if (target instanceof HTMLElement) target.focus()
return
}
const titles = letters.filter((letter) => chosen.has(letter.id)).map((letter) => letter.title)
setSubscribed(copy.subscribed.replace("{email}", email).replace("{letters}", AS_SENTENCE.format(titles)))
form.reset()
}
return (
<form noValidate onSubmit={submit} className="flex flex-col gap-6">
<FieldSet className="gap-3">
<FieldLegend variant="label">{copy.legend}</FieldLegend>
<div className="grid gap-3 sm:grid-cols-2">
{letters.map((letter) => (
<LetterCard
key={letter.id}
letter={letter}
chosen={chosen.has(letter.id)}
invalid={Boolean(errors.topics)}
errorId={topicsErrorId}
onChange={(on) => choose(letter.id, on)}
/>
))}
</div>
{errors.topics ? <FieldError id={topicsErrorId}>{errors.topics}</FieldError> : null}
</FieldSet>
<div className="flex flex-col gap-2">
<Label htmlFor={inputId}>{copy.label}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? `${emailErrorId} ${noteId}` : noteId}
className="h-10 sm:flex-1"
/>
<Button type="submit" size="lg" className="h-10 px-5">
{copy.submit}
</Button>
</div>
{errors.email ? <FieldError id={emailErrorId}>{errors.email}</FieldError> : null}
<p id={noteId} className="text-xs text-muted-foreground">
{copy.note}
</p>
</div>
<p role="status" aria-live="polite" className="-mt-3 min-h-5 text-sm">
{subscribed ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{subscribed}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `newsletter-04` sets: the four letters a reader can choose
* between — each with how often it arrives and what it carries, and whether
* it starts chosen — and the form's labels, refusals and confirmation.
* Replace the letters with your own lists.
*/
export type Letter = { id: string; title: string; cadence: string; description: string; chosen: boolean }
/** What the form says, handed to its island as plain props. */
export type TopicFormCopy = {
legend: string
label: string
placeholder: string
submit: string
note: string
/** The refusal when no letter is chosen. */
noTopic: string
empty: string
invalid: string
/** `{email}` and `{letters}` — the chosen letters, joined as a sentence — are put in from the form. */
subscribed: string
}
export const EYEBROW = "Northwind by email"
export const HEADLINE = "Choose what lands in your inbox"
export const LEDE =
"Four letters, each on its own schedule. Pick any of them, and change your mind from the link at the foot of every email."
export const LETTERS: readonly Letter[] = [
{
id: "weekly-number",
title: "The Weekly Number",
cadence: "Every Tuesday",
description: "One metric taken apart: what moves it and how to read it.",
chosen: true,
},
{
id: "product-updates",
title: "Product updates",
cadence: "Twice a month",
description: "What shipped, and how to use it on your own numbers.",
chosen: false,
},
{
id: "benchmarks",
title: "Benchmarks",
cadence: "Every quarter",
description: "How 1,200 teams compare on growth, retention and support.",
chosen: false,
},
{
id: "events",
title: "Events",
cadence: "A few times a year",
description: "Webinars and meetups near you, with the recordings after.",
chosen: false,
},
]
export const FORM: TopicFormCopy = {
legend: "Letters",
label: "Work email",
placeholder: "name@example.com",
submit: "Subscribe",
note: "One click leaves any of them.",
noTopic: "Choose at least one letter.",
empty: "Enter your email address to subscribe.",
invalid: "Enter an email address in the format name@example.com.",
subscribed: "Subscribed {email} to {letters}.",
}npx shadcn@latest add @vibra/newsletter-04Newsletter strip
newsletter-05A slim strip for the top of a changelog or the middle of a post: an icon, what the list sends and how often in one line, and the field with its button.
import * as React from "react"
import { MailIcon } from "lucide-react"
import { BannerSignup } from "./newsletter-05.client"
import { HEADLINE, LINE, SIGNUP } from "./newsletter-05.data"
/**
* The sign-up at the size of a strip, for the top of a changelog or the
* middle of a blog post: an icon, what the list sends and how often in one
* line, and the field and its button on the same row from `lg` — stacked
* under the words below it, never wrapping the button under the field.
*
* The h2 names the band and the form both, at the size of a strip rather
* than a banner. The form is the one island; this file is a server component
* and every word is in `./newsletter-05.data`.
*/
export function Newsletter05() {
const titleId = React.useId()
return (
<section
data-section="newsletter-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-5 frame p-5 sm:px-6 lg:flex-row lg:items-center lg:gap-10">
<div className="flex items-start gap-4 lg:flex-1">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-card text-foreground ring-1 ring-border">
<MailIcon aria-hidden="true" className="size-4" />
</span>
<div className="flex min-w-0 flex-col gap-1">
<h2 id={titleId} className="text-base font-semibold text-balance">
{HEADLINE}
</h2>
<p className="text-sm text-pretty text-muted-foreground">{LINE}</p>
</div>
</div>
<div className="w-full lg:w-104 lg:shrink-0">
<BannerSignup copy={SIGNUP} labelledBy={titleId} />
</div>
</div>
</div>
</section>
)
}
export default Newsletter05"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { BannerSignupCopy } from "./newsletter-05.data"
/**
* The strip's sign-up: the field and its button on one line at every width,
* the label read to assistive technology only, since the strip's heading
* says what the field is for. The form is named by that heading. It refuses
* in its own words — an empty field and a malformed address each get a
* message tied to the field, which is marked invalid and takes focus back —
* and confirms an accepted address in a status line that is always mounted
* but takes no room until it has something to say, so the strip stays slim.
*
* It keeps no list: wire the accepted address to your mailing provider where
* `setSubscribed` is called.
*/
export function BannerSignup({ copy, labelledBy }: { copy: BannerSignupCopy; labelledBy: string }) {
const inputId = React.useId()
const errorId = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [error, setError] = React.useState<string | null>(null)
const [subscribed, setSubscribed] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
const problem = email === "" ? copy.empty : isEmail(email) ? null : copy.invalid
setError(problem)
if (problem) {
setSubscribed(null)
inputRef.current?.focus()
return
}
setSubscribed(email)
event.currentTarget.reset()
}
return (
<form noValidate onSubmit={submit} aria-labelledby={labelledBy} className="flex w-full flex-col">
<Label htmlFor={inputId} className="sr-only">
{copy.label}
</Label>
<div className="flex gap-2">
<Input
ref={inputRef}
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className="h-9 min-w-0 flex-1"
/>
<Button type="submit" size="lg" className="shrink-0">
{copy.submit}
</Button>
</div>
{error ? (
<FieldError id={errorId} className="mt-2">
{error}
</FieldError>
) : null}
<p role="status" aria-live="polite" className="text-sm not-empty:mt-2">
{subscribed ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{copy.subscribed.replace("{email}", subscribed)}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `newsletter-05` sets: what the strip offers in a line, and its
* field's label, refusals and confirmation. The label is read by assistive
* technology only — the strip's heading says what the field is for — so it
* still has to say it plainly. Replace the values with your own.
*/
/** What the sign-up says, handed to its island as plain props. */
export type BannerSignupCopy = {
/** Read to assistive technology; the strip's heading carries it visually. */
label: string
placeholder: string
submit: string
empty: string
invalid: string
/** `{email}` is replaced with the address that subscribed. */
subscribed: string
}
export const HEADLINE = "Get the changelog by email"
export const LINE = "One email when something ships, about twice a month. Nothing else."
export const SIGNUP: BannerSignupCopy = {
label: "Work email for the changelog",
placeholder: "name@example.com",
submit: "Subscribe",
empty: "Enter your email address.",
invalid: "Enter an address like name@example.com.",
subscribed: "Subscribed. The next changelog goes to {email}.",
}npx shadcn@latest add @vibra/newsletter-05Newsletter with an issue preview
newsletter-06The sign-up beside a whole issue as it arrived: who sent it and when, its title, the figure with twelve weeks behind it and what to check this week.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Sparkline } from "@/components/ui/sparkline"
import { PreviewSignup } from "./newsletter-06.client"
import { BROWSER_LINK, EYEBROW, HEADLINE, LEDE, PREVIEW, SIGNUP } from "./newsletter-06.data"
/**
* The sign-up beside the thing itself: a whole issue as it arrived — who sent
* it and when, its title, the figure with the twelve months behind it, what
* to check this week and the line at its foot — so the reader knows exactly
* what they are subscribing to. No screenshot: the issue is drawn from kit
* parts, so it takes the reader's palette and mode and stays sharp.
*
* The issue is a figure with its caption; its headers are a description
* list, its chart the kit's Sparkline, which reads its values out. The form
* is the one island; this file is a server component and every word and
* figure is in `./newsletter-06.data`.
*/
export function Newsletter06() {
const titleId = React.useId()
const captionId = React.useId()
return (
<section
data-section="newsletter-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-12 lg:gap-16">
<div className="flex flex-col items-start lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8 w-full">
<PreviewSignup copy={SIGNUP} />
</div>
<Link
href={BROWSER_LINK.href}
className="group/browser mt-2 inline-flex items-center gap-1 rounded-sm text-sm font-medium underline-offset-4 focus-ring hover:underline"
>
{BROWSER_LINK.label}
<ArrowRightIcon
aria-hidden="true"
className="size-3.5 transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/browser:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
<figure aria-labelledby={captionId} className="flex flex-col gap-3 lg:col-span-7">
<Card>
<CardHeader>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs">
{PREVIEW.headers.map((header) => (
<div key={header.label} className="contents">
<dt className="text-muted-foreground">{header.label}</dt>
<dd className="truncate">{header.value}</dd>
</div>
))}
</dl>
</CardHeader>
<CardContent className="flex flex-col gap-5 p-5 sm:p-7">
<div className="flex flex-col gap-2">
<p className="type-eyebrow">{PREVIEW.issue}</p>
<h3 className="text-xl font-semibold text-balance">{PREVIEW.title}</h3>
<p className="text-sm/6 text-pretty text-muted-foreground">{PREVIEW.intro}</p>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-surface p-4 ring-1 ring-border">
<div className="flex items-baseline justify-between gap-4">
<p className="text-xs font-medium text-pretty">
{PREVIEW.chart.label}
</p>
<p className="shrink-0 text-right">
<span className="type-numeral text-xl">{PREVIEW.chart.latest}</span>{" "}
<span className="text-xs text-muted-foreground">{PREVIEW.chart.latestLabel}</span>
</p>
</div>
<Sparkline data={PREVIEW.chart.values} type="bar" height={64} />
</div>
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold">{PREVIEW.checks.heading}</h4>
<ol className="flex list-decimal flex-col gap-1 pl-5 text-sm/6 marker:text-muted-foreground">
{PREVIEW.checks.items.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</div>
</CardContent>
<CardFooter>
<p className="text-xs text-pretty text-muted-foreground">{PREVIEW.footer}</p>
</CardFooter>
</Card>
<figcaption id={captionId} className="text-center text-xs text-muted-foreground">
{PREVIEW.caption}
</figcaption>
</figure>
</div>
</section>
)
}
export default Newsletter06"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { SignupCopy } from "./newsletter-06.data"
/**
* The sign-up on one line: a labelled field and its button side by side from
* `sm`, the note that describes the field under them. The browser's own
* validation is off so the form refuses in its own words — an empty field
* and a malformed address each get a message tied to the field, which is
* marked invalid and takes focus back. An accepted address clears the field
* and is confirmed in the status line, which is always mounted so the
* confirmation is announced as it arrives. The refusal and the confirmation
* never share a slot.
*
* It keeps no list: wire the accepted address to your mailing provider where
* `setSubscribed` is called.
*/
export function PreviewSignup({ copy }: { copy: SignupCopy }) {
const inputId = React.useId()
const errorId = React.useId()
const noteId = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [error, setError] = React.useState<string | null>(null)
const [subscribed, setSubscribed] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
const problem = email === "" ? copy.empty : isEmail(email) ? null : copy.invalid
setError(problem)
if (problem) {
setSubscribed(null)
inputRef.current?.focus()
return
}
setSubscribed(email)
event.currentTarget.reset()
}
return (
<form noValidate onSubmit={submit} className="flex w-full flex-col gap-2 text-left">
<Label htmlFor={inputId}>{copy.label}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
ref={inputRef}
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${errorId} ${noteId}` : noteId}
className="h-10 sm:flex-1"
/>
<Button type="submit" size="lg" className="h-10 px-5">
{copy.submit}
</Button>
</div>
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
<p id={noteId} className="text-xs text-muted-foreground">
{copy.note}
</p>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{subscribed ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{copy.subscribed.replace("{email}", subscribed)}
</span>
) : null}
</p>
</form>
)
}/**
* Every word and figure `newsletter-06` sets: the sign-up — label, refusals
* and confirmation — and the issue it shows in full, as it arrived: who sent
* it and when, its title, the figure and the twelve months behind it, what to
* check this week and the line at its foot. The issue went out on the
* Tuesday before the sample world's `REFERENCE_DATE`, which the section's
* test holds it to, and its chart ends on the week its first line names.
* Replace it with an issue of your own.
*/
/** What the sign-up says, handed to its island as plain props. */
export type SignupCopy = {
label: string
placeholder: string
submit: string
note: string
empty: string
invalid: string
/** `{email}` is replaced with the address that subscribed. */
subscribed: string
}
export type PreviewLink = { label: string; href: string }
export const EYEBROW = "The Weekly Number"
export const HEADLINE = "See an issue before you sign up"
export const LEDE =
"This is issue 112 as it reached 6,400 inboxes last Tuesday. Every issue keeps the same shape: the figure, the chart, what to check this week and the query behind it."
export const SIGNUP: SignupCopy = {
label: "Work email",
placeholder: "name@example.com",
submit: "Subscribe",
note: "Tuesdays at 07:00 UTC. One click to unsubscribe.",
empty: "Enter your email address to subscribe.",
invalid: "Enter an email address in the format name@example.com.",
subscribed: "Subscribed. Issue 113 reaches {email} on Tuesday.",
}
export const BROWSER_LINK: PreviewLink = { label: "Read issue 112 in your browser", href: "/newsletter/112" }
export const PREVIEW = {
caption: "Issue 112 of the Weekly Number, as it arrived.",
/** The day it went out, a UTC day, and how the header prints it. */
sent: "2026-09-01",
headers: [
{ label: "From", value: "The Weekly Number <weekly@example.com>" },
{ label: "Subject", value: "Why net revenue retention dipped in August" },
{ label: "Sent", value: "Tue 1 Sep 2026, 07:00 UTC" },
],
issue: "Issue 112",
title: "Why net revenue retention dipped in August",
intro:
"Net revenue retention fell from 104% to 99% in August. Almost all of the drop came from two accounts that moved to a smaller plan in the same week.",
chart: {
label: "Revenue lost to smaller plans, week by week",
/** Twelve weeks to the week of 24 August, in thousands of dollars: the last is the week the intro names. */
values: [3.9, 4.2, 3.6, 4.4, 4.1, 3.8, 4.6, 4.0, 4.3, 3.7, 4.5, 21.2],
latest: "$21.2k",
latestLabel: "week of Aug 24",
},
checks: {
heading: "Three things to check this week",
items: [
"Accounts that changed plan in the last 30 days",
"Seats used against seats bought, account by account",
"Renewals due in the next 60 days where usage is falling",
],
},
footer: "You get this because you subscribed to the Weekly Number. One click unsubscribes.",
}npx shadcn@latest add @vibra/newsletter-06