Email layout
ProThe shell every transactional email uses: brand, title, body, one next step (a command to run, or a button), the ids it refers to, and why you got it. Table layout and inline styles so it survives email clients; render it to an HTML string with renderEmail.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/email-layout
Usage
"use client"
import * as React from "react"
import { EmailPreview, renderEmail, type EmailNotice } from "@/components/email-layout"
import { Button } from "@/components/ui/button"
const notice: EmailNotice = {
preheader: "Add region to customers is waiting for an approver on Acme web Production.",
title: "An approval is waiting for you",
paragraphs: ["Keel asked for approval of a migration to Acme web Production. You’re one of the people who can decide it.", "It expires in 7 days."],
next: { kind: "command", text: "keel approval show apr_7f3a91" },
refs: [
{ label: "Request", value: "apr_7f3a91" },
{ label: "Change hash", value: "sha256:9f2c4e81b7a0…" },
],
why: "You’re getting this because you’re an approver on Acme web Production.",
}
export default function Example() {
const [frame, setFrame] = React.useState(false)
// renderEmail's string goes to your mailer. EmailPreview shows the same email inline; the iframe shows the exact document.
const html = React.useMemo(() => renderEmail(notice, "Keel"), [])
return (
<div className="flex max-w-xl flex-col gap-3">
<EmailPreview notice={notice} brand="Keel" className="border" />
<Button size="sm" variant="outline" className="self-start" aria-expanded={frame} onClick={() => setFrame((o) => !o)}>
{frame ? "Hide the HTML document" : "Show the HTML document"}
</Button>
{frame && <iframe title="Email document" srcDoc={html} sandbox="" className="block h-[30rem] w-full rounded-xl border bg-white" />}
</div>
)
}<EmailLayout>
| Prop | Type | Default | Description |
|---|---|---|---|
| noticerequired | EmailNotice | — | What the email says: preheader, title, body, next step, ids and why it was sent. |
| brandrequired | string | — | The product name shown at the top. |
| colors | EmailColors | undefined | defaultEmailColors | Fixed hex colours to match your brand. Defaults to the default theme's light values. |
| strings | Partial<typeof emailLayoutStrings> | undefined | — | The email's fixed words, e.g. `{ nextRun: "Ensuite, lancez :" }`. Passed directly: emails render outside any LocaleProvider. |
| lang | string | undefined | "en" | The email's language, for `<html lang>`. |
<EmailPreview>
The same email inline, for a settings page or the docs: no <html> document or iframe, its own fixed colours so it looks as it will in a mail client, and its title at a heading level that fits your page.
| Prop | Type | Default | Description |
|---|---|---|---|
| noticerequired | EmailNotice | — | What the email says. |
| brandrequired | string | — | The product name shown at the top. |
| colors | EmailColors | undefined | defaultEmailColors | Fixed hex colours; defaults to the default theme's light values. |
| strings | Partial<typeof emailLayoutStrings> | undefined | — | The email's fixed words. |
| headingLevel | 2 | 3 | 4 | undefined | 3 | The heading level of the email's title on your page. Default 3. |
| label | string | undefined | — | The preview's accessible name. Default: "Email preview: " and the title. |
| className | string | undefined | — | Extra classes for the outer element. |
Functions
renderEmail(notice: EmailNotice, brand: string, colors?: EmailColors, words?: { strings?: Partial<typeof emailLayoutStrings> | undefined; lang?: string | undefined })The email as a complete HTML document, ready for the mailer.
Strings
The fixed words, in English by default. Change any of them under emailLayout in <LocaleProvider strings={{ … }}>; the table is exported as emailLayoutStrings.
| Key | English |
|---|---|
| nextRun | Next, run: |
| preview | Email preview |
Types
- EmailColors
{ ground: string; card: string; ink: string; muted: string; border: string; accent: string; code: string }- EmailNextStep
| { kind: "command"; text: string } | { kind: "link"; label: string; href: string }- EmailNotice
{ /** Shown in the inbox preview. */ preheader: string title: string paragraphs: string[] next?: EmailNextStep | undefined /** Ids the notice carries, e.g. a request id and a hash, rather than the data itself. */ refs?: { label: string; value: string }[] | undefined /** Why the person got this, e.g. "You can approve changes to Shop production." */ why: string }