Modules
A template's body is a stack of modules. Each top-level block component is one module, and each run of loose Markdown between components becomes an implicit letter. The layout picks the spacing between two modules from their classes, so a template never sets margins or padding.
Look up a module
A component can be written with its descriptive name or with its alias, the handoff ID (k1, T2, c3…). Both are matched case-insensitively and compile to the same module.
| Module (alias) | Class | Attributes and bindings | Content | Rules |
|---|---|---|---|---|
statement (k1) | Hero | eyebrow, tone="orange", size="xl" | headings h1–h3, paragraphs, lists, action rows | At most one heading. |
figure (k2) | Hero | eyebrow, amount (required), usd | paragraphs | |
offer (e3) | Hero | eyebrow | one ## heading, then one terms paragraph | The terms paragraph is required. |
image (k3) | Band | src, alt, height=340, tone | none | Must directly follow a statement. alt is required with src. |
map (t4) | Band | src, alt, pickup, dropoff, height=260 | none | Needs alt, or both pickup and dropoff. |
letter (b1) | Body | none | paragraphs, ### headings, lists, :link rows | Over 4 paragraphs is a warning. |
meta (t3) | Body | none | ### Label + paragraph pairs | 1–4 pairs. |
quote (e2) | Body | cite | one paragraph | |
notice (b2) | Notice | title, tone="warn" | 1–2 paragraphs | |
breakdown (b4) | Tabular | none | one GFM table | Exactly 2 columns. The header row renders as a body row. |
compare (b5) | Tabular | none | one GFM table with a header | At most 3 columns. |
table | Tabular | headless | one GFM table | At most 4 columns. |
receipt (t1) | Tabular | :order → data.Receipt (required), total_label | none | |
stock (t5) | Tabular | :items → []data.StockItem (required) | none | |
stories (e1) | Editorial | none | 1–4 :::story tiles | |
products (e4) | Editorial | :items → []data.Product, cta | or 1–4 :::product tiles | :items or tiles, not both. |
code (b3) | Tail | value (required) | at most one paragraph | |
progress (t2) | Tail | steps or :steps → []string; current or :current | none | 2–5 steps. A literal current must be a step. |
actions (c1, c2, c3) | Tail | none | one paragraph of only :button and :link | |
signature (s1) | Signoff | name (required), role, contact | none | |
signoff (s2) | Signoff | closing, name | an optional paragraph (the closing) | closing as an attribute or a paragraph, not both. |
divider | Rule | none | none |
Know the shared rules
These apply to every module:
- Attributes and props are one set of keys. Any attribute can also be given in a props block (see Pass props). Setting a key both ways is
statement: "eyebrow" set twice. Bindings are the exception: a:keyis read only from the attribute list. A props key":order"is ignored, so::receiptwith only that prop isreceipt: missing binding ":order". - Unknown keys are errors. A key the module does not read, an
#idor a.classisfigure: unknown attribute "tone". - Values are typed. Text values can hold merge tags.
heightmust be an integer (image: "height" must be an integer), a flag must be bare,trueorfalse(table: "headless" must be a flag), and an enumerated value must be one of its options (statement: tone must be "orange"). toneonimage,:::storyand:::productis one ofwarm,sea,sunormap: the coloured ground shown when there is no image (image: tone must be one of warm, sea, sun, map).- "none" means none. A module whose content column says none rejects any content:
receipt: content is not allowed. A block a module does not take is<module>: <what> is not allowed here, for examplefigure: h1 is not allowed hereornotice: action row is not allowed here. - Families can forbid modules. A family's
Forbiddenlist is checked at compile time:module "image" is not allowed in family "security".
The previews below are the real emails of examples/quiet, rendered from the templates in the gallery.
Open with a hero
A hero opens the email: an eyebrow line, then the one thing the email is about.
statement
statement (k1) is the main hero: an eyebrow, a heading, copy, a list and the email's actions.
| Attribute | Type | Notes |
|---|---|---|
eyebrow | text | The small line above the heading. |
tone | "orange" | The eyebrow in the accent colour. |
size | "xl" | The large heading. |
::statement{eyebrow="Order {{order_id}}"}
# On its way.
{{driver.first_name}} will reach {{destination}} by {{eta}}.
:button[Track delivery]{href="{{track_url}}"} :link[View order]{href="{{order_url}}"}
::
It takes headings of level 1 to 3, but at most one (statement: at most one heading). Its action rows may hold the template's one :button — see Actions and spans. Seven of the nine Quiet examples open with a statement; the previews under receipt and code show two.
figure
figure (k2) leads with an amount: a payout, a refund, a balance.
| Attribute | Type | Notes |
|---|---|---|
eyebrow | text | The line above the amount. |
amount | text | Required (figure: missing "amount"). |
usd | text | A secondary line under the amount, such as an approximate US$ value. |
::figure{eyebrow="Weekly payout · {{period}}" amount="{{amount}}" usd="≈ {{amount_usd}}"}
It should reach your account ending {{account_last4}} within 48 hours.
::
The content is paragraphs only.
offer
offer (e3) is a promotional hero: a ## heading, often with the offer code as a code span, and a terms paragraph.
::offer{eyebrow="This week only"}
## 15% off with `HERITAGE15`
On island-made goods until 27 Sept. Minimum spend EC$100.
::
The content must be exactly a ## heading followed by one paragraph. Anything else is offer: expected a ## heading then a terms paragraph, offer: terms paragraph is required or offer: only a ## heading and one terms paragraph are allowed.
Add a band
A band runs edge to edge: an image or a map.
image
image (k3) is a full-width picture, or a coloured ground when there is no src.
| Attribute | Type | Notes |
|---|---|---|
src | text | The image URL. Without it, the band is the tone ground alone. |
alt | text | Required when src is set (image: alt is required when src is set). |
height | integer | Pixels, default 340. |
tone | warm, sea, sun, map | The ground colour. |
::statement{eyebrow="Makers" tone="orange"}
# The woodworkers of Old Road
::
::image{src="{{cover_url}}" alt="A bench in Old Road" tone="warm"}
::
An image must directly follow a statement: anywhere else it is image must directly follow a statement. At render time, a src that evaluates to text needs an alt that does too.
map
map (t4) is a static map image, such as a delivery route.
| Attribute | Type | Notes |
|---|---|---|
src | text | The map image URL. Without it, the band is the map ground alone. |
alt | text | The image description. |
pickup, dropoff | text | When alt is not set, the alt is Map from {pickup} to {dropoff}. |
height | integer | Pixels, default 260. |
::map{src="{{map_url}}" pickup="{{pickup_area}}" dropoff="{{dropoff_area}}"}
::
Give either alt or both pickup and dropoff (map: alt or pickup and dropoff are required). Build src with the mapbox package and host the image on your own CDN: the Mapbox API URL carries your token.
Write the body
letter
letter (b1) is plain correspondence. You rarely write it: every run of loose Markdown between components is compiled as a letter.
Hi {{first_name | there}},
We're lowering commission on services bookings from 1 November.
:link[Read the full policy]{href="https://example.com/policies/fees"}
::letter … :: (or ::b1) does the same explicitly and takes no attributes. A letter holds paragraphs, ### headings, lists whose items are one paragraph each, and action rows of :link only: :button is letter: :button is not allowed; use :link. Other headings are errors (letter: h1 is not allowed here), so put a big heading in a statement. More than four paragraphs is the warning letter: more than 4 paragraphs.
meta
meta (t3) is a list of labelled details, laid out in two columns that stack on mobile.
::meta
### Deliver to
{{deliver_to.name}}
{{deliver_to.address}}
### Driver
{{driver.name}} · ★ {{driver.rating}}
::
The content must alternate a ### Label heading and one paragraph (meta: expected "### label" then a paragraph), with 1 to 4 pairs (meta: 1 to 4 pairs, got 5). A line break inside the paragraph is kept.
quote
quote (e2) is a pull quote with an optional cite line.
::quote{cite="Dwayne Liburd · Brimstone Woodcraft"}
"Every board leaves Old Road with the grain facing the sea."
::
The content is exactly one paragraph (quote: expected one paragraph).
Call out a notice
notice
notice (b2) is a boxed note, with an optional bold title. tone="warn" gives it the warning colours.
::notice{title="Didn't ask for this?"}
Your password hasn't changed. If you keep getting these, [secure your account]({{security_url}}).
::
It holds one or two paragraphs (notice: 1 or 2 paragraphs, got 3). Links in the text are fine; action rows are not.
Show a table
Tabular modules hold one GFM table or a bound list. Cells are inline Markdown: spans, strong, code spans and links. In a cell, write a merge tag fallback as {{x \| y}} — see Build tables. A table module whose content is anything but one table is breakdown: expected one table.
breakdown
breakdown (b4) is a two-column list of labels and values. The header row renders as an ordinary body row, so the first line of the table is data.
::breakdown
| Sales · {{order_count}} orders | {{sales}} |
| --- | ---: |
| Commission · {{commission_rate}} | {{commission}} |
| [Reference]{.small .muted} | [`{{reference}}`]{.small .muted} |
::
The table must have exactly 2 columns (breakdown: table must have 2 columns, has 3).
compare
compare (b5) shows values before and after a change. The header row is a real header.
::compare
| Commission | Now | From 1 Nov |
| --- | ---: | ---: |
| Goods | 6% | 6% |
| Services | 4% | **3.5%** |
::
At most 3 columns (compare: table must have at most 3 columns, has 4). With three columns the middle ("now") column renders smaller and in the secondary colour, and **bold** in a value cell renders as the new value, bold in the action colour (D13).
table
table is a general table of up to 4 columns (table: table must have at most 4 columns, has 5). The headless flag renders the header row as a body row.
::table
| Date | Where | Time |
| --- | --- | ---: |
| **Sat 5 Oct** | Basseterre office | 9:00 am |
| **Sat 12 Oct** | Charlestown ferry terminal | 10:00 am |
::
receipt
receipt (t1) is an order summary built from a data.Receipt in your data: line items with thumbnails, fees and a total.
::receipt{:order="order"}
::
| Key | Type | Notes |
|---|---|---|
:order | binding → data.Receipt | Required (receipt: missing binding ":order"). Any other type is receipt: :order is string, want data.Receipt. |
total_label | text | The label of the total row. Without it, Receipt.TotalLabel, then "Total". |
data.Receipt holds Lines (Name, Detail, Qty, Amount, Thumb, Tone), Fees (Label, Amount), Total and an optional TotalUSD. A line's sub-line reads Detail · Qty N in HTML, with the quantity shown when it is above 0.
stock
stock (t5) lists stock levels from a []data.StockItem (Name, SKU, Left). Each row reads "N left", or "Out of stock" at zero.
::stock{:items="low_stock"}
::
:items is required (stock: missing binding ":items") and must be exactly []data.StockItem (stock: :items is []string, want []data.StockItem). An empty list is a render error, since a silently empty section would leave the layout's spacing wrong. None of the Quiet examples uses stock.
Show tiles
Editorial modules lay tiles out two to a row. An odd last tile gets an empty filler cell on desktop.
stories
stories (e1) is a row of 1 to 4 story tiles. Each tile is a :::story whose body is one paragraph, the title.
::stories
:::story{eyebrow="Stays" href="https://example.com/journal/stays" tone="sea"}
Four guesthouses on the Nevis Peak trail
:::
:::story{eyebrow="Food" image="{{sauce_img}}" href="{{sauce_url}}" cta="Get the recipe"}
What goes into Betty's Hope pepper sauce
:::
::
:::story attribute | Type | Notes |
|---|---|---|
href | text | Required (story: missing "href"). |
eyebrow | text | The line above the title. |
image | text | The tile image URL. Without it, the tile shows its tone ground. |
alt | text | Defaults to the title's text (D11). |
cta | text | The link label, default "Read". |
tone | warm, sea, sun, map | The placeholder ground. |
Only :::story components may sit inside (stories: only :::story is allowed here), 1 to 4 of them (stories: 1 to 4 stories, got 5), and each body must be one paragraph (story: body must be one paragraph (the title)).
products
products (e4) is a grid of 1 to 4 product tiles, either bound to a []data.Product or written out as :::product tiles.
::products{:items="products" cta="Shop"}
::
::products
:::product{name="Mahogany bench" price="EC$450" image="{{bench_img}}" href="{{bench_url}}" tone="warm"}
:::
::
| Key | On | Notes |
|---|---|---|
:items | ::products | Binding → exactly []data.Product (Name, Price, Href, Image, Tone). |
cta | ::products, :::product | The link label, default "Buy". A tile's own cta wins. |
name, price, href | :::product | Required (product: missing "price"). |
image, alt, tone | :::product | alt defaults to name (D11). |
Use :items or tiles, not both (products: use :items or :::product children, not both), and one of them is required (products: missing :items or :::product children). A :::product has no content. An empty bound list is a render error.
End with a tail
code
code (b3) shows a one-time code in large monospace type, with an optional expiry line.
::code{value="{{otp_code}}"}
Requested {{requested_at}} from {{device}} near {{location}}.
::
value is required (code: missing "value"). Spaces in the rendered value are removed, so "482 913" shows as 482913. The content is at most one paragraph (code: at most one paragraph).
progress
progress (t2) is a step tracker of 2 to 5 steps.
::progress{steps="Confirmed, Packed, On its way, Delivered" current="On its way"}
::
::progress{:steps="steps" :current="step"}
::
| Key | Type | Notes |
|---|---|---|
steps | literal text | A comma-separated list of 2 to 5 steps (progress: 2 to 5 steps, got 6). No merge tags (progress: steps must be literal text). |
:steps | binding → []string | The steps from your data (progress: :steps is string, want []string). The count is checked at render. |
current | text | The current step: a step's exact name, or its 1-based number (D12). |
:current | binding | A string (a step name) or an integer (a step number), checked at render. |
Give steps or :steps (progress: missing steps), and at most one of current and :current. Both in one {…} list is the parse error duplicate attribute "current"; a props current together with a :current attribute is progress: "current" set twice plus progress: set current or :current, not both (the same holds for steps and :steps). A literal current is checked against literal steps at compile time: progress: current "Shipped" is not a step. Without a current step, every step renders as not reached.
actions
actions (c1, c2, c3) is a row of buttons and links on its own, after the other modules.
::actions
:button[View payout]{href="{{payout_url}}"} :link[See orders]{href="{{orders_url}}"}
::
The content must be exactly one paragraph made only of :button and :link (actions: expected one paragraph of :button/:link). The three aliases compile to the same module. See Actions and spans for the rules of each action.
Sign off
signature
signature (s1) is a personal sign-off: a name, a role and a contact line.
::signature{name="Marisa Hodge" role="Partnerships Lead" contact="marisa@example.com · +1 869 555 0142"}
::
name is required (signature: missing "name"), and the module takes no content.
signoff
signoff (s2) is a team sign-off: a closing line and a name.
::signoff{closing="Thanks for selling with us,"}
::
| Attribute | Default | Notes |
|---|---|---|
closing | "Thanks," | Or write the closing as the module's one paragraph, which may use inline Markdown. |
name | The {brand} team | {brand} is Theme.Brand.Name (D7). |
Set the closing one way only (signoff: set closing as an attribute or a paragraph, not both). Any content other than one paragraph is signoff: expected one paragraph (the closing).
Separate modules
divider
divider is a horizontal rule between two modules. It takes no attributes and no content (divider: content is not allowed).
::divider
::
A Markdown --- between modules is not a divider: it is the error use ::divider instead of ---.
Next steps
- Merge tags and bindings for the data paths used above.
- Actions and spans for
:button,:linkand span classes. - Layout and spacing for how classes set the space between modules.