<!--
  Generated from docs/ — do not edit here; run `pnpm regen`.
  Canonical: https://cascivo.com/docs/email-primitives.md
  registry v1.6.0 · generated 2026-10-02
-->
<!-- AUTO-GENERATED by packages/email/scripts/generate-primitives.ts — do not edit by hand. -->
<!-- Run `pnpm email:primitives:generate` (or `pnpm regen`) to refresh. -->

# Email primitives

Every component `@cascivo/email` exports, with its props and a worked example. Live previews of
all 22, rendered as real email documents, are at [https://cascivo.com/docs/email/components](https://cascivo.com/docs/email/components). The
full set of 12 themes is demonstrated on complete templates at
[https://cascivo.com/docs/email](https://cascivo.com/docs/email).

These are **not** the `@cascivo/react` components. HTML email is a separate render target:
no flexbox, no grid, no custom properties, no `rem`, no client JavaScript. Layout is tables and
styling is inline, because Outlook Windows still renders through Microsoft Word. See
[RECIPE-EMAIL.md](/docs/recipe-email.md) for the whole workflow and
[EMAIL-CLIENT-SUPPORT.md](/docs/email-client-support.md) for what the conformance lint enforces.

```tsx
import { Body, Button, Container, Head, Html, Preview, Section, Text } from '@cascivo/email'
```

Every primitive below also takes `children` (its content) and `style` — inline declarations merged over the primitive's own, in camelCase, with lengths written in `px`.

## Contents

- **Document** — [`Html`](#html), [`Head`](#head), [`Body`](#body), [`Preview`](#preview), [`Style`](#style)
- **Layout** — [`Container`](#container), [`Section`](#section), [`Row`](#row), [`Column`](#column), [`Spacer`](#spacer), [`Hr`](#hr)
- **Typography** — [`Heading`](#heading), [`Text`](#text), [`Link`](#link), [`List`](#list), [`Footer`](#footer)
- **Content** — [`Button`](#button), [`Card`](#card), [`Alert`](#alert), [`Badge`](#badge), [`Img`](#img), [`Markdown`](#markdown)

## Document

### Html

The root element.

| Prop       | Type             | Default | Notes                                                                                                         |
| ---------- | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------- |
| `children` | `ReactNode`      | —       |                                                                                                               |
| `lang`     | `string`         | `'en'`  | BCP 47 language tag. Also set on `<body>`, because some clients strip `<html>`.                               |
| `dir`      | `'ltr' \| 'rtl'` | `'ltr'` | Text direction. `rtl` flips the reading order; the primitives use no physical properties that would fight it. |

**The document shell**

Every email starts here. `Html` carries the XHTML doctype, the namespaces Outlook needs for VML, and the `lang`/`dir` pair that a right-to-left message sets once.

```tsx
<Html lang="en" dir="ltr">
  <Head title="Your receipt" />
  <Body>
    <Container>
      <Section padding={24}>
        <Heading level={2}>Your receipt</Heading>
        <Text>Thanks — your payment went through.</Text>
      </Section>
    </Container>
  </Body>
</Html>
```

### Head

Document head.

| Prop       | Type        | Default | Notes                                                        |
| ---------- | ----------- | ------- | ------------------------------------------------------------ |
| `children` | `ReactNode` | —       |                                                              |
| `title`    | `string`    | —       | Rendered as `<title>`. Some clients show it; most ignore it. |

**Title and client meta**

The `title` is what a browser tab and several webmail clients show. Everything else a mail client needs — the viewport tag, the colour-scheme pair, the Outlook pixel-density fix — is emitted for you.

```tsx
<Html>
  <Head title="Reset your password" />
  <Body>
    <Container>
      <Section padding={24}>
        <Text>Check the tab title.</Text>
      </Section>
    </Container>
  </Body>
</Html>
```

### Body

Document body, painted with the theme background.

| Prop       | Type             | Default | Notes                                                                                             |
| ---------- | ---------------- | ------- | ------------------------------------------------------------------------------------------------- |
| `children` | `ReactNode`      | —       |                                                                                                   |
| `style`    | `Style`          | —       |                                                                                                   |
| `lang`     | `string`         | `'en'`  | Repeated from `Html` — several clients strip `<html>` and graft the body into their own document. |
| `dir`      | `'ltr' \| 'rtl'` | `'ltr'` | Repeated from `Html`, for the same reason `lang` is.                                              |

**Themed background**

The background and foreground come from the theme, resolved to literal sRGB — no client supports a custom property. Switch the theme above and this frame is the only thing that has to change.

```tsx
<Html>
  <Head title="Body" />
  <Body>
    <Container>
      <Section padding={24}>
        <Heading level={3}>Painted by the theme</Heading>
        <Text variant="muted">Twelve themes, one component tree.</Text>
      </Section>
    </Container>
  </Body>
</Html>
```

### Preview

The inbox preview text — the grey line beside the subject.

| Prop                      | Type     | Default | Notes                                                                             |
| ------------------------- | -------- | ------- | --------------------------------------------------------------------------------- |
| `children` **(required)** | `string` | —       | The inbox preview line. Aim for under ~90 characters; clients truncate past that. |

**The inbox line**

Deliberately invisible in the frame — it is the grey line the inbox shows beside the subject. Without it the client repeats the opening words of the body instead. Keep it under ~90 characters.

```tsx
<Html>
  <Head title="Reset your password" />
  <Body>
    <Preview>Reset your password — the link expires in 30 minutes</Preview>
    <Container>
      <Section padding={24}>
        <Heading level={2}>Reset your password</Heading>
        <Text>The link below expires in 30 minutes.</Text>
      </Section>
    </Container>
  </Body>
</Html>
```

### Style

A `<style>` block, hoisted into `<head>` by the renderer.

| Prop                      | Type     | Default | Notes                                                        |
| ------------------------- | -------- | ------- | ------------------------------------------------------------ |
| `children` **(required)** | `string` | —       | CSS text. Written verbatim — it is authored, not user input. |

**A rule inline styles cannot express**

Media queries and pseudo-classes are the only two things that need a stylesheet. Declare the rule next to the component that needs it — the renderer hoists every block into `<head>` and collapses duplicates.

```tsx
<Html>
  <Head title="Style" />
  <Body>
    <Container>
      <Section padding={24}>
        <Style>{'@media only screen and (max-width:600px){.hero{font-size:24px!important}}'}</Style>
        <Text className="hero" size="32px">
          Big on desktop, smaller on a phone
        </Text>
      </Section>
    </Container>
  </Body>
</Html>
```

## Layout

### Container

Centred fixed-width column — the outermost content wrapper.

| Prop         | Type        | Default         | Notes                                                                |
| ------------ | ----------- | --------------- | -------------------------------------------------------------------- |
| `children`   | `ReactNode` | —               |                                                                      |
| `width`      | `number`    | `CONTENT_WIDTH` | Content width in pixels.                                             |
| `responsive` | `boolean`   | `true`          | Emit the width override that lets the container go fluid on a phone. |
| `breakpoint` | `number`    | —               | Viewport width, in pixels, below which the container goes fluid.     |
| `className`  | `string`    | —               | Extra class, for a rule of your own in a `Style` block.              |
| `style`      | `Style`     | —               |                                                                      |

**The centred column**

Centred by both `margin: 0 auto` and `align="center"`, because neither works in every client. The default width is 600px, which clears every desktop reading pane; this one is narrowed to 400 so the centring is visible. `responsive` adds the width override that stops a phone scrolling sideways.

```tsx
<Container width={400}>
  <Section padding={24}>
    <Card padding={16}>
      <Text align="center">400px, centred</Text>
    </Card>
  </Section>
</Container>
```

### Section

A full-width band of content.

| Prop         | Type                            | Default | Notes                                                                                                        |
| ------------ | ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `children`   | `ReactNode`                     | —       |                                                                                                              |
| `padding`    | `number \| string`              | `0`     | Vertical padding in pixels, applied to the cell.                                                             |
| `background` | `string`                        | —       | Band colour. A literal — no client resolves a custom property, so pass a hex or call `token()`.              |
| `align`      | `'left' \| 'center' \| 'right'` | —       | Horizontal alignment of the cell's content, emitted as the `align` attribute so nested tables follow it too. |
| `className`  | `string`                        | —       | Extra class, for a rule of your own in a `Style` block.                                                      |
| `style`      | `Style`                         | —       |                                                                                                              |

**Bands of content**

One table, one cell — padding on the cell, because Outlook drops padding on a `<div>`. A `background` makes it a full-bleed band.

```tsx
<Container>
  <Section padding={32} background="#1f2937" align="center">
    <Heading level={2} align="center" style={{ color: '#ffffff' }}>
      Acme
    </Heading>
  </Section>
  <Section padding={24}>
    <Text>And an unpainted band below it, with its own padding.</Text>
  </Section>
</Container>
```

### Row

A horizontal group of Columns.

| Prop        | Type        | Default | Notes                                                   |
| ----------- | ----------- | ------- | ------------------------------------------------------- |
| `children`  | `ReactNode` | —       |                                                         |
| `className` | `string`    | —       | Extra class, for a rule of your own in a `Style` block. |
| `style`     | `Style`     | —       |                                                         |

**Columns side by side**

A `<tr>` that never inspects its children — it emits the row and trusts each `Column` to emit its cell. Flexbox and grid are unsupported in Outlook Windows; this is the layout primitive.

```tsx
<Row>
  <Column width="50%" padding={8}>
    <Text>Ordered</Text>
  </Column>
  <Column width="50%" padding={8} align="right">
    <Text>3 items</Text>
  </Column>
</Row>
```

### Column

One cell of a Row.

| Prop        | Type                            | Default | Notes                                                                                           |
| ----------- | ------------------------------- | ------- | ----------------------------------------------------------------------------------------------- |
| `children`  | `ReactNode`                     | —       |                                                                                                 |
| `width`     | `number \| string`              | —       | Width as a percentage or pixel count. Set both attribute and style.                             |
| `align`     | `'left' \| 'center' \| 'right'` | —       | Horizontal alignment of the cell's content. A `Button` with no `align` of its own follows this. |
| `valign`    | `'top' \| 'middle' \| 'bottom'` | `'top'` | Vertical alignment within the row.                                                              |
| `padding`   | `number \| string`              | —       | Cell padding in pixels, or any CSS padding shorthand.                                           |
| `stack`     | `boolean`                       | `false` | Become a full-width block below the breakpoint, so a row reflows into a stack.                  |
| `className` | `string`                        | —       | Extra class, for a rule of your own in a `Style` block.                                         |
| `style`     | `Style`                         | —       |                                                                                                 |

**Alignment and stacking**

Set `stack` on each column that should become full-width on a phone — never on the `Row`. A button inside follows its column, so a right-aligned cell puts the button on the right.

```tsx
<Row>
  <Column width="50%" padding={8} stack>
    <Text>Stacks below 600px</Text>
  </Column>
  <Column width="50%" padding={8} align="right" stack>
    <Button href="https://example.com">Open</Button>
  </Column>
</Row>
```

### Spacer

Vertical space.

| Prop                    | Type     | Default | Notes             |
| ----------------------- | -------- | ------- | ----------------- |
| `height` **(required)** | `number` | —       | Height in pixels. |

**Vertical space that survives Outlook**

A margin would be simpler and is not dependable — Outlook collapses and ignores margins in several positions. This is a row with a stated height and a pinned line box.

```tsx
<Section>
  <Card padding={16}>
    <Text>Above</Text>
  </Card>
  <Spacer height={24} />
  <Card padding={16}>
    <Text>Below</Text>
  </Card>
</Section>
```

### Hr

A rule.

| Prop      | Type     | Default | Notes                             |
| --------- | -------- | ------- | --------------------------------- |
| `style`   | `Style`  | —       |                                   |
| `spacing` | `number` | `0`     | Space above and below, in pixels. |

**A rule**

Drawn as a bordered cell, not `<hr>` — Outlook gives `<hr>` its own inset 3D border and ignores most styling on it.

```tsx
<Section>
  <Text>Order summary</Text>
  <Hr spacing={16} />
  <Text variant="muted">Subtotal, tax and total follow.</Text>
</Section>
```

## Typography

### Heading

A section heading.

| Prop       | Type                            | Default | Notes                                                                                                                 |
| ---------- | ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
| `children` | `ReactNode`                     | —       |                                                                                                                       |
| `level`    | `HeadingLevel`                  | `1`     | Semantic level. Drives the element, and the default size unless `size` overrides it.                                  |
| `size`     | `string`                        | —       | Font size, overriding the one `level` implies. A CSS length — `rem` is unsupported in two floor clients, so use `px`. |
| `align`    | `'left' \| 'center' \| 'right'` | —       |                                                                                                                       |
| `style`    | `Style`                         | —       |                                                                                                                       |

**Levels**

The element follows `level`; `size` overrides the size alone, for the common case of an `<h2>` that has to look like an `<h1>`.

```tsx
<Section>
  <Heading level={1}>Level one</Heading>
  <Spacer height={8} />
  <Heading level={2}>Level two</Heading>
  <Spacer height={8} />
  <Heading level={3}>Level three</Heading>
</Section>
```

### Text

A paragraph.

| Prop        | Type                            | Default     | Notes                                                                                                                            |
| ----------- | ------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `children`  | `ReactNode`                     | —           |                                                                                                                                  |
| `size`      | `string`                        | `'16px'`    | Font size as a CSS length. Never below `14px`: iOS Mail inflates smaller text to its own minimum and the layout shifts under it. |
| `variant`   | `'default' \| 'muted'`          | `'default'` | `muted` for secondary copy — reads from `--cascivo-color-text-muted`.                                                            |
| `align`     | `'left' \| 'center' \| 'right'` | —           |                                                                                                                                  |
| `className` | `string`                        | —           | Extra class, for a rule of your own in a `Style` block.                                                                          |
| `style`     | `Style`                         | —           |                                                                                                                                  |

**Body copy**

Never set a size below 14px: iOS Mail inflates smaller text to its own minimum and the layout shifts under it.

```tsx
<Section>
  <Text>Default body copy, 16px, at the theme foreground.</Text>
  <Spacer height={12} />
  <Text variant="muted">Muted secondary copy for the supporting line.</Text>
  <Spacer height={12} />
  <Text align="center" size="14px">
    Centred, and one step down.
  </Text>
</Section>
```

### Link

An inline link.

| Prop                  | Type        | Default | Notes                                                                      |
| --------------------- | ----------- | ------- | -------------------------------------------------------------------------- |
| `children`            | `ReactNode` | —       |                                                                            |
| `href` **(required)** | `string`    | —       | Absolute URL. A relative path resolves against the mail client's own host. |
| `ping`                | `string`    | —       | Click-beacon URLs, space-separated — the `ping` attribute.                 |
| `style`               | `Style`     | —       |                                                                            |

**Inline link**

Reads `--cascivo-color-accent-text` rather than the accent fill — four of the twelve themes pick an accent that fails contrast as type.

```tsx
<Section>
  <Text>
    Trouble with the button?
    <Link href="https://cascivo.com/docs/email">Open the page directly</Link>.
  </Text>
</Section>
```

### List

A bulleted or numbered list.

| Prop                   | Type          | Default | Notes                             |
| ---------------------- | ------------- | ------- | --------------------------------- |
| `items` **(required)** | `ReactNode[]` | —       | One entry per list item.          |
| `ordered`              | `boolean`     | `false` | Render `<ol>` rather than `<ul>`. |
| `style`                | `Style`       | —       |                                   |

**Bulleted and numbered**

Both `margin` and `padding` are stated because Outlook mis-indents a `<ul>` without them.

```tsx
<Section>
  <List items={['Unlimited seats', 'CSV export', 'Audit log']} />
  <Spacer height={16} />
  <List ordered items={['Open the link', 'Choose a password', 'Sign in']} />
</Section>
```

### Footer

The closing block — smaller, muted, centred.

| Prop       | Type        | Default | Notes |
| ---------- | ----------- | ------- | ----- |
| `children` | `ReactNode` | —       |       |
| `style`    | `Style`     | —       |       |

**The closing block**

Smaller, muted and centred, with the padding on a cell. The unsubscribe line belongs here.

```tsx
<Footer>
  <Text size="14px" variant="muted" align="center">
    Acme, Inc · Berlin
  </Text>
  <Text size="14px" variant="muted" align="center">
    <Link href="https://example.com/unsubscribe">Unsubscribe</Link>
  </Text>
</Footer>
```

## Content

### Button

A call-to-action button.

| Prop                  | Type                                        | Default     | Notes                                                                                                     |
| --------------------- | ------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- |
| `children`            | `ReactNode`                                 | —           |                                                                                                           |
| `href` **(required)** | `string`                                    | —           | Absolute URL. A relative path resolves against the mail client's own host.                                |
| `variant`             | `'primary' \| 'secondary' \| 'destructive'` | `'primary'` | `primary` for the main action, `secondary` for a supporting one, `destructive` for anything that deletes. |
| `align`               | `'left' \| 'center' \| 'right'`             | —           | Horizontal placement. Omit it to follow the cell the button sits in.                                      |
| `block`               | `boolean`                                   | `false`     | Full-width call to action.                                                                                |
| `ping`                | `string`                                    | —           | Click-beacon URLs, space-separated — the `ping` attribute. Same caveats as `Link`'s.                      |
| `style`               | `Style`                                     | —           |                                                                                                           |

**Variants**

An `<a>` painted as a block inside its own table — a `<button>` does nothing in an email and several clients strip it. The corners are square in Outlook Windows, which is the one deliberate degradation.

```tsx
<Section>
  <Button href="https://example.com">Primary</Button>
  <Spacer height={12} />
  <Button href="https://example.com" variant="secondary">
    Secondary
  </Button>
  <Spacer height={12} />
  <Button href="https://example.com" variant="destructive">
    Destructive
  </Button>
</Section>
```

**Full width, and following its cell**

Omit `align` and the button follows the `align` of the `Column` or `Section` around it. Set it only inside a cell you aligned by hand with `style`.

```tsx
<Section>
  <Button href="https://example.com" block>
    Confirm your address
  </Button>
  <Spacer height={12} />
  <Section align="center">
    <Button href="https://example.com" variant="secondary">
      Centred by its cell
    </Button>
  </Section>
</Section>
```

### Card

A bordered surface.

| Prop        | Type        | Default | Notes                                                                                      |
| ----------- | ----------- | ------- | ------------------------------------------------------------------------------------------ |
| `children`  | `ReactNode` | —       |                                                                                            |
| `padding`   | `number`    | `24`    | Inset in pixels, applied to the cell — padding on a `<div>` is dropped in Outlook Windows. |
| `className` | `string`    | —       | Extra class, for a rule of your own in a `Style` block.                                    |
| `style`     | `Style`     | —       |                                                                                            |

**A bordered surface**

One table, one cell, border and padding both on the cell — the only arrangement Outlook renders with the border in the right place.

```tsx
<Card padding={24}>
  <Heading level={3}>Pro plan</Heading>
  <Spacer height={8} />
  <Text variant="muted">€29 per seat, per month. Cancel any time.</Text>
  <Spacer height={16} />
  <Button href="https://example.com">Upgrade</Button>
</Card>
```

### Alert

A callout.

| Prop       | Type                                                | Default  | Notes                                                                                                    |
| ---------- | --------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `children` | `ReactNode`                                         | —        |                                                                                                          |
| `tone`     | `'info' \| 'success' \| 'warning' \| 'destructive'` | `'info'` | Drawn as a border and a tint, never an icon — images are blocked by default in a large share of clients. |
| `title`    | `string`                                            | —        | Optional bold lead-in above the body copy.                                                               |
| `style`    | `Style`                                             | —        |                                                                                                          |

**Tones**

The tone is a 4px border plus a tint, never an icon — icons would have to be images, and images are blocked by default in a large share of clients, which would hide the tone exactly where it matters.

```tsx
<Section>
  <Alert tone="info" title="Heads up">
    Your trial ends on Friday.
  </Alert>
  <Spacer height={12} />
  <Alert tone="success">Payment received.</Alert>
  <Spacer height={12} />
  <Alert tone="warning">Your card expires next month.</Alert>
  <Spacer height={12} />
  <Alert tone="destructive">We could not charge your card.</Alert>
</Section>
```

### Badge

A small status pill.

| Prop       | Type                                                             | Default     | Notes                                                                      |
| ---------- | ---------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| `children` | `ReactNode`                                                      | —           |                                                                            |
| `tone`     | `'neutral' \| 'success' \| 'warning' \| 'destructive' \| 'info'` | `'neutral'` | Carried by colour alone, so pair it with a label that says the same thing. |
| `style`    | `Style`                                                          | —           |                                                                            |

**Tones**

The one primitive knowingly imperfect in Outlook Windows: a badge has to be inline, and inline padding is only partially supported there. The spaces either side are the fallback.

```tsx
<Section>
  <Text>
    <Badge tone="success">Paid</Badge> <Badge tone="warning">Pending</Badge>{' '}
    <Badge tone="destructive">Failed</Badge> <Badge tone="info">New</Badge> <Badge>Draft</Badge>
  </Text>
</Section>
```

### Img

A raster image.

| Prop                   | Type     | Default | Notes                                                                                                                   |
| ---------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
| `src` **(required)**   | `string` | —       | Absolute URL. A relative path resolves against the client's own host and 404s.                                          |
| `alt` **(required)**   | `string` | —       | Required. An image with no alt text is unreadable in the ~40% of clients that block images by default.                  |
| `width` **(required)** | `number` | —       | Intrinsic display width in pixels. Required: Outlook sizes from the attribute and renders at intrinsic size without it. |
| `height`               | `number` | —       | Fixed height, in pixels, emitted as the `height` attribute only.                                                        |
| `href`                 | `string` | —       | Wrap the image in a link.                                                                                               |
| `style`                | `Style`  | —       |                                                                                                                         |

**A raster image**

`src` must be an absolute URL — a relative path resolves against the client’s own host. `alt` and `width` are required by the type: images are blocked by default in a large share of clients, and Outlook renders at intrinsic size without the attribute. SVG is not usable.

```tsx
<Section align="center">
  <Img src="https://cascivo.com/icon-192.png" alt="cascivo" width={96} href="https://cascivo.com" />
</Section>
```

### Markdown

Render a Markdown string through the cascivo email primitives.

| Prop                      | Type     | Default         | Notes                                           |
| ------------------------- | -------- | --------------- | ----------------------------------------------- |
| `children` **(required)** | `string` | —               | The Markdown source.                            |
| `spacing`                 | `number` | `16`            | Vertical space between blocks, in pixels.       |
| `imageWidth`              | `number` | `CONTENT_WIDTH` | Width in pixels for `![alt](https://…)` images. |

**Prose you do not have at build time**

Every node renders through the primitives above, never through raw HTML — so a CMS string is exactly as conformance-checkable as a hand-composed tree. Anything outside the allowlist stays literal text rather than failing the send.

```tsx
<Markdown>
  {`## Changes this week

We shipped **two** things you asked for:

- Per-seat billing, prorated
- CSV export on every report

Read the [full notes](https://cascivo.com/docs/changelog).`}
</Markdown>
```
