Skip to content
Component installs need the registry setup
VegaStack Design

email-kit

Server-rendered HTML email — Notification, Digest and Action templates in light and dark, built from the design tokens, with renderEmail for HTML and text.

Last updated

Light
Dark

What it is

Everything an app needs to send good-looking transactional and notification email from a server: EmailLayout (the shell), eight parts, three templates and renderEmail. It is built on React Email — tables with role="presentation", a 600px fluid column, the system font stack, inline light colours and one small <style> block for dark mode and phones. Every string comes in through props, so the same three templates cover every email an app sends. It is a block: you pull it once with shadcn add, and then you own it.

The colours are the design tokens converted to hex (email clients read neither CSS variables nor OKLCH) in email-tokens.ts, which is generated — never edit it by hand. The kit is server-safe: no 'use client', no hooks.

Install

npx shadcn@latest add @vegastack/email-kit
Registry setup required
Run this command only after configuring the Base UI shadcn project, the @vegastack registry namespace, and the Cloudflare Access service-token headers in Install from the VegaStack registry.

This installs eight files into components/email/ and adds one npm dependency, react-email. It composes no other registry component — email cannot use the app's CSS.

Preview

Each preview is the real template rendered by renderEmail, shown light and dark.

Light
Dark

Rendering and sending

renderEmail returns the HTML part and the plain-text part. Hand both to your mail provider; set the subject yourself.

import { NotificationEmail } from "@/components/email/notification-email";
import { renderEmail } from "@/components/email/render";

const { html, text } = await renderEmail(
  <NotificationEmail
    brand={{ name: workspace.name, url: workspace.url }}
    preview={`${actor.name}: ${quote}`}
    heading={`${actor.name} commented on ${task.title}`}
    quotes={[{ author: actor.name, time: formatted, body: quote }]}
    action={{ label: "View comment", href: commentUrl }}
    footer={{
      reason: "You're getting this because you follow this task.",
      links: [
        { label: "Manage notifications", href: settingsUrl },
        { label: "Unsubscribe from comment emails", href: unsubscribeUrl },
      ],
    }}
  />,
);
await mailer.send({ to, subject: task.title, html, text });
  • Call it from a route handler, a server action or a job — it uses react-dom/server.
  • The plain-text part skips the preheader, keeps headings in sentence case and writes each link as its text and URL.
  • Each template renders to about 8–11 KB, far under Gmail's 102 KB clip.

Templates

NotificationEmail

One event about one record — an assignment, a mention, a comment, a change, a reminder. The heading carries the event; quotes, a record card and one button follow. For a batch of events on one record, pass a count line as the heading ("3 new comments on Fix login") and the quotes oldest first.

PropTypeDefaultDescription
action*EmailAction—The one call to action.
brand*EmailBrand—The wordmark at the top — the workspace or product name.
heading*string—Who did what to what: "Priya commented on Fix login", or a count line for a collapsed batch.
preview*string—The inbox preview line: the event sentence or the quote.
bodystring—An optional sentence under the heading.
cardEmailCardProps—A summary of the record the event is about.
dir"ltr" | "rtl""ltr"Text direction.
footerEmailFooter—Why they got it, the preference links, and the closing note.
langstring"en"Language of the email.
quotesEmailQuoteProps[][]Quoted comments or messages, oldest first.
titlestringheadingThe document <title>.

DigestEmail

Many events in one email — the daily digest or due-date reminders: groups of linked rows, an optional "more" line, and one button (usually "Open inbox").

Light
Dark
PropTypeDefaultDescription
action*EmailAction—The one call to action, usually "Open inbox".
brand*EmailBrand—The wordmark at the top — the workspace or product name.
groups*EmailItemGroup[]—The grouped rows — by record or by section ("Due today").
heading*string—The digest's heading: "Your day in Acme".
preview*string—The inbox preview line: what is in the digest ("3 comments and 2 tasks due today").
bodystring—An optional line under the heading: "5 updates since yesterday".
dir"ltr" | "rtl""ltr"Text direction.
footerEmailFooter—Why they got it, the preference links, and the closing note.
langstring"en"Language of the email.
morestring—A line under the list when it was cut short: "And 4 more in your inbox."
titlestringheadingThe document <title>.

ActionEmail

A message that asks for one action — an invite, a password reset, an access request. Body paragraphs, an optional card, one button, the raw link for clients that block the button, and a muted note (expiry, "you can ignore this"). Transactional email usually has no preference links in its footer.

Light
Dark
PropTypeDefaultDescription
action*EmailAction—The one call to action: "Accept invite", "Reset password", "Review request".
brand*EmailBrand—The wordmark at the top — the workspace or product name.
heading*string—What the person is asked to do: "Priya invited you to Acme".
preview*string—The inbox preview line.
bodystring | string[]—One paragraph, or several.
cardEmailCardProps—The thing the action is about — a space, a record, a request.
dir"ltr" | "rtl""ltr"Text direction.
footerEmailFooter—The closing note and links. Transactional email usually has no preference links.
langstring"en"Language of the email.
linkFallbackstring—The label before the raw URL, for clients that block the button ("Or paste this link into your browser:"). Omit to leave the URL out.
notestring—A muted line under the button: expiry, or "If you didn't expect this, you can ignore it."
titlestringheadingThe document <title>.

Layout and parts

Compose your own template from the same pieces when none of the three fits.

ExportWhat it renders
EmailLayout<html lang>, the colour-scheme metas, the preheader, the wordmark, the card and the footer
EmailHeadingThe heading — h1 (20px) once per email, h2 (16px) for a section
EmailTextA paragraph; tone="muted" for secondary copy
EmailLinkAn underlined inline link
EmailButtonThe one call to action — 46px tall, full width on a phone
EmailCardA record summary: its title (linked) and label/value rows
EmailQuoteA quoted comment with its author and time
EmailItemsGrouped rows for a digest: Actor did something · time
EmailDividerA hairline
<EmailLayout brand={{ name: "Acme" }} preview="Your export is ready">
  <EmailHeading>Your export is ready</EmailHeading>
  <EmailText>The CSV has 1,204 rows.</EmailText>
  <EmailButton href={downloadUrl}>Download CSV</EmailButton>
</EmailLayout>
PropTypeDefaultDescription
brand*EmailBrand—The wordmark at the top of the email.
children*ReactNode—The card's content — compose the parts from email-parts.
preview*string—The inbox preview line (preheader), at most ~150 characters. Hidden in the body and the plain-text part.
dir"ltr" | "rtl""ltr"Text direction.
footerEmailFooter—The footer under the card.
langstring"en"The language of the email, set on <html> and <body>.
titlestringpreviewThe document <title>; some clients show it in a browser view.

The footer note defaults to "Sent with VegaStack"; pass note: null to hide it. Pass noteHref to link it: on the default note only "VegaStack" becomes the link, on a string note the whole line does. For anything else, note also takes inline content, such as text with a Link from react-email.

<EmailLayout
  brand={{ name: "Acme" }}
  preview="Your export is ready"
  footer={{ noteHref: "https://vegastack.com" }}
>
  …
</EmailLayout>
PropTypeDefaultDescription
linksEmailFooterLink[]—Preference and unsubscribe links, shown in one row.
noteReactNode"Sent with VegaStack"The closing line: text, or inline content such as text with a Link from react-email. null hides it.
noteHrefstring—Absolute URL that links the note. On the default note only the word "VegaStack" is linked ("Sent with [VegaStack]"); on a string note the whole line is. Ignored when note is an element (link inside it instead) or null.
reasonstring—Why the recipient got this email, e.g. "You're getting this because you're assigned to this task."
PropTypeDefaultDescription
title*string—The record's title.
childrenReactNode—Extra content under the meta rows.
hrefstring—Link for the title; omit for plain text.
metaEmailCardMeta[][]Label/value rows under the title.
PropTypeDefaultDescription
body*string—The quoted text, as plain text. Each line break becomes a <br> (and a line in the plain-text part).
authorstring—Who wrote it.
timestring—When, already formatted with its zone ("Fri 9 Oct, 10:00 IST").
PropTypeDefaultDescription
text*string—What happened, after the actor: "commented on Fix login".
actorstring—Who did it — a person, or the agent's name. Omit for system events.
hrefstring—Where the row links.
timestring—When, already formatted ("10:00 IST").

EmailMarkdown

Long-form Markdown — meeting notes, a page excerpt — in the same reading styles as the in-app notes: the heading scale, lists, task checkboxes, quotes and code, with the kit's dark-mode hooks. It never injects HTML: raw HTML and images are dropped, mention links read as their bold label, and only http(s) links and in-app paths (resolved against appUrl) stay links.

Light
Dark
PropTypeDefaultDescription
appUrl*string—The app's origin; in-app paths (/tasks/1) resolve against it.
markdown*string—The Markdown source (CommonMark + GFM).

Dark mode and email clients

  • Light is the default and is inline, so it survives every client, including the ones that strip <style>.
  • Dark mode is two small <style> blocks: @media (prefers-color-scheme: dark) for Apple Mail, iOS Mail and Outlook on Mac, and — in a block of its own, so a client that rejects attribute selectors cannot drop the first — [data-ogsc]/[data-ogsb] rules for Outlook.com and the new Outlook, which repaint text and fill together. Elements opt in with vs-* class hooks.
  • Classic Outlook ignores max-width, so renderEmail wraps the column in a 600px table only Outlook sees (conditional comments React cannot write; the layout leaves two empty markers that applyMsoGhostTable replaces). Render with renderEmail, or call applyMsoGhostTable yourself.
  • Forced inversion (Gmail, which ignores prefers-color-scheme, and classic Outlook) recolours the email itself. The palette avoids pure white and black, which those clients special-case, and every text colour is AA (4.5:1) on its background in both modes — checked when email-tokens.ts is generated.
  • No images. The header is a text wordmark from brand.name, so nothing is blocked or inverted.
  • Phones. Under 600px the gutters tighten and the button goes full width; it is at least 44px tall everywhere.
  • Rounded corners and the dark block are progressive: classic Outlook on Windows shows square corners and the light design.

Writing notifications

One rule set for every channel. The in-app item is always sent; push and email follow the person's preferences.

In-appPushEmail
ShapeOne sentenceTitle + bodySubject, preheader, heading, quote or card, one button, footer
ExamplePriya commented on Fix loginTitle "Priya" · body "Commented on Fix login: Can we ship this by Friday?"Subject "Fix login" · heading "Priya commented on Fix login"
QuotesCut at a word, 120 characters, "…"Cut at a word, 110 characters, "…"In full
TimesRelative ("2m")RelativeAbsolute with the zone ("Fri 9 Oct, 10:00 IST")
Title of a system event—The record ("Fix login") — body "Due today at 18:00"Subject "Fix login" · heading "Fix login is due today"
  • Who → did what → to what. Past tense, active voice, sentence case: "Priya assigned you to Fix login", not "You have been assigned a task".
  • Use the specific verb. "Priya moved Fix login to In review", never "updated" when you know the field.
  • No "!", emoji, capitals for emphasis, "please" or "successfully".
  • Quotes are plain text: whitespace collapsed, cut at a word with "…".
  • Push title is the person for people-to-you events and the record for system, agent and reminder events; the body is the action, plus the quote.
  • Email subject is the record's title, the same for every email about that record so clients thread them. Events with no record ("Priya added you to Design", access requests) use the sentence. The preheader carries the sentence or the quote.
  • One button, verb first: "View task", "View comment", "Open page", "View meeting", "Review request", "Open space"; a digest's is "Open inbox".
  • Footer: why they got it ("You're getting this because you're assigned to this task." / "…you were mentioned." / "…you follow this task." / "…you're a member of Design."), then "Manage notifications", "Unsubscribe from comment emails", and "Sent with VegaStack".
  • Names are data. The product is VegaStack and the place is the workspace's name ("Acme") — never a customer name baked into a template.
Do
Subject "Fix login" · heading "Priya commented on Fix login" · button "View comment".
Don't
Subject "New activity!" · heading "You have a new notification" · button "Click here".

Files

FileRegistry typeTarget
email-kit/email-tokens.tsregistry:componentcomponents/email/email-tokens.ts
email-kit/mso.tsregistry:componentcomponents/email/mso.ts
email-kit/email-layout.tsxregistry:componentcomponents/email/email-layout.tsx
email-kit/email-parts.tsxregistry:componentcomponents/email/email-parts.tsx
email-kit/email-markdown.tsxregistry:componentcomponents/email/email-markdown.tsx
email-kit/notification-email.tsxregistry:componentcomponents/email/notification-email.tsx
email-kit/digest-email.tsxregistry:componentcomponents/email/digest-email.tsx
email-kit/action-email.tsxregistry:componentcomponents/email/action-email.tsx
email-kit/render.tsregistry:componentcomponents/email/render.ts

Do / Don't

Do
Pass every string — the heading, the button label, the footer reason — from your notification catalog.
Don't
Fork a template to change one sentence, or edit email-tokens.ts by hand; regenerate it from the tokens.

On this page