@nxgt/mail-ui
| Install | |
|---|---|
npm install @nxgt/mail-ui |
|
| Latest Version: | 1.0.1 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 14, 2026 |
| Links: | GitHub · npm |
@nxgt/mail-ui
E-mail components for a normal Maizzle 6 project, in
the style of @nxgt/material-vue: <NxLayout>, <NxButton>, <NxCard>,
<NxAlert>, <NxTable>, <NxTimeline>, <NxStatCard>, <NxActionCard>… with its variants,
colours and tokens, rendered with tables and
inlined styles. One plugin gives every template the components, your brand
and the theme; a second export gives @nxgt/mail-i18n the messages they share.
// maizzle.config.ts
import { defineMailConfig } from '@nxgt/mail-config';
import { i18n } from '@nxgt/mail-i18n';
import { ui, uiCatalogues } from '@nxgt/mail-ui';
export default defineMailConfig({
plugins: [
ui({ brand: { name: 'Acme', url: 'https://acme.example' } }),
i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] }),
],
});
Your project stays a Maizzle project: emails/, components/, public/,
maizzle serve, maizzle build. Maizzle's own components (<Button>,
<Spacer>) stay available; ours carry the Nx prefix and never shadow them.
The components from NxLayout to NxCode in one e-mail, with the brand
Acme and the default theme —
in French.
1.x. Semantic versioning: a breaking change waits for the next major, and the changelog says what each release changes.
Install
bun add @nxgt/mail-ui @nxgt/mail-config @maizzle/framework @maizzle/tailwindcss vue
Peers, all required:
@maizzle/framework(^6.1.7) — Maizzle itself.@nxgt/mail-config—ui()is a plugin for itsdefineMailConfig. It needs@maizzle/tailwindcss(^1.5.6) as a direct dependency of your project; see Setup.vue(^3.5) — the components are Vue single-file components, and the package typesbrandon Vue's template properties.typescript(6). Bundler resolution ("moduleResolution": "bundler") is what is supported and tested;nodenextis out of contract.
Runs wherever maizzle build does: Node ^22.22.3, ^24.15.0 or >=26 — the
range Maizzle 6's own dependencies require — or Bun. CI runs the tests on
Bun, and builds the starter under Node 22.22.3.
@nxgt/mail-i18n is not a peer: add it (bun add @nxgt/mail-i18n) to
translate your e-mails and to use uiCatalogues.
One dependency comes with the package: unplugin-vue-components, which
Maizzle already uses. Maizzle resolves no tag in a .vue file under
node_modules, so ui() resolves them itself for the files a package ships —
ours, and a package's templates such as @nxgt/mail-presets'. See
Components from a package.
Setup
/* what <NxLayout> writes in its <style> — you do not write it */
@import "@maizzle/tailwindcss";
That import is resolved from your project. Under an isolated install (Bun
workspaces, pnpm) it fails silently unless @maizzle/tailwindcss is in
your own package.json, as the bun add above makes it: the build succeeds,
and no style is generated.
// tsconfig.json — the official starter's include; keep .maizzle/*.d.ts in it
{ "include": ["**/*.vue", ".maizzle/*.d.ts"] }
// package.json — the starter's postinstall
{ "scripts": { "postinstall": "maizzle prepare" } }
Each time the config loads (maizzle prepare, serve, build), ui()
writes .maizzle/nxgt-mail-ui.d.ts, which loads the type of brand for the
templates. The starter's tsconfig.json does not include
maizzle.config.ts, so this file is how the editor learns it. .maizzle/ is
in the starter's .gitignore. See
Editor and type checking.
Exports
| Export | What it is |
|---|---|
ui(options) |
The plugin, for defineMailConfig({ plugins }): the Nx* components, brand in every template, the theme |
uiCatalogues |
The shared messages in en and fr, for i18n({ catalogues: [uiCatalogues] }) |
UI_CONTEXT |
'nxgt:mail-ui', the Vue provide key a component reads the brand and the theme's CSS from |
COMPONENTS_DIR |
The absolute path of the package's components/ folder |
THEME_FILE |
The absolute path of the package's theme.css |
Brand, UiOptions, UiContext |
The brand, the plugin's options, and what UI_CONTEXT provides |
| Subpath | What it is |
|---|---|
@nxgt/mail-ui |
The exports above |
@nxgt/mail-ui/theme.css |
The theme: @theme tokens for Tailwind 4 |
Usage
A template
<!-- emails/verify-email.vue -->
<template>
<NxLayout :preheader="t('verify-email.title')">
<NxTypography variant="headline-small">{{ t('verify-email.title') }}</NxTypography>
<NxTypography>{{ t('common.greeting', { name: placeholder('name') }) }}</NxTypography>
<NxButton :href="placeholder('link')">{{ t('verify-email.action') }}</NxButton>
<NxSpacer />
<NxAlert variant="warning" title="The link expires in 15 minutes." />
<NxTypography variant="caption">{{ t('common.footer.ignore') }}</NxTypography>
</NxLayout>
</template>
<NxLayout> is the page: the brand's logo or name at the top, the content on
a card, and a footer that says why the e-mail came (common.footer.why) and
links the brand. Every other component goes inside it. t and placeholder
come from @nxgt/mail-i18n; without it, write the text directly. See
Components for every component, its props and
its slots.
The components
Each one mirrors the @nxgt/material-vue component of the same name, without
the Nx — except NxSpacer and NxCode, the e-mail's own:
- Page and text —
NxLayout,NxTypography,NxLink,NxSeparator,NxSpacer,NxExtendedLabel,NxHighlightText,NxKbd,NxCode. - Buttons —
NxButton,NxLinkButton,NxIconButton,NxButtonGroup. - Boxes and statuses —
NxCardand its parts,NxActionCard,NxFigure,NxBadge,NxCountBadge,NxAlert,NxBanner,NxStatusIndicator,NxChip. - Data —
NxSummaryData,NxTableand its parts,NxDescription,NxListTile,NxAvatarandNxAvatarGroup. - Sequences —
NxProgress,NxStepsandNxStepsItem,NxTimeline. - Summaries —
NxHero,NxEntityHeader,NxStatCard,NxGoalCard,NxRatioCard,NxCompareCard,NxBreakdownCard,NxSeeAlso. - Details —
NxEventChip(an invitation's date and time),NxAttributes,NxPostalAddress,NxOpeningHours,NxContacts,NxFileList(attachments or downloads),NxRating(read only, or a row of review links).
<template>
<NxExtendedLabel>
Your inbox
<template #trailing><NxCountBadge :count="3">Unread</NxCountBadge></template>
</NxExtendedLabel>
<NxActionCard title="Weekly digest" description="One e-mail each Monday." active />
<NxButtonGroup>
<NxButton href="https://acme.example/inbox" data-state="active">Inbox</NxButton>
<NxIconButton href="https://acme.example/starred" icon="★" tooltip="Starred" />
</NxButtonGroup>
<NxSpacer size="lg" />
<NxLinkButton to="https://acme.example/preferences">Manage your preferences</NxLinkButton>
</template>
<template>
<NxEventChip title="Onboarding call" :time="placeholder('time')" />
<NxFileList :items="[{ id: 'agenda', name: 'agenda.pdf', size: 1572864, href: placeholder('agendaLink') }]" />
<NxRating label="How was it?" :href="(star) => `https://acme.example/review?rating=${star}`" />
</template>
The plugin — ui({ brand, theme })
import { ui } from '@nxgt/mail-ui';
ui({
brand: {
name: 'Acme',
url: 'https://acme.example',
logo: { src: 'https://acme.example/logo.png', width: 96, alt: 'Acme' },
},
theme: { 'color-primary': '#0f766e', 'radius-xl': '8px' },
});
| Option | Type | Default | Effect |
|---|---|---|---|
brand.name |
string |
— (required) | Shown in the header without a logo, and in the footer always |
brand.url |
string |
none | An absolute http(s) URL the header and the footer link to |
brand.logo |
{ src, width?, alt?, darkSrc? } |
none | The header's image: src an absolute http(s) URL, width in pixels (120), alt (the brand's name), darkSrc shown instead of src under dark mode |
theme |
Record<string, string> |
{} |
Tokens of theme.css to override, named without their -- |
A wrong option is a bare TypeError when the config loads:
ui: brand.logo.src must be an absolute http(s) URL — a mail client loads nothing relative. Every template also gets brand: {{ brand.name }}. See
The plugin.
The theme
theme.css holds material-vue's light tokens. Maizzle turns each colour into
a hex value in the built HTML, and material-vue's translucent bg-primary/15
is bg-primary-15 here, a plain colour, because a mail client drops an
alpha. Override a colour, and its tints follow:
ui({ brand: { name: 'Acme' }, theme: { 'color-primary': '#0f766e' } });
// <NxButton> is #0f766e; <NxButton variant="tonal"> is #dbeae9, 15% of it over white
See The theme for every token.
Dark mode
NxLayout follows the mail client's dark theme, in the clients that support
one, with @nxgt/material-vue's dark palette. A component that colours by a
token needing a dark value (bg-card, text-foreground, border-border, …)
carries the matching class alongside it:
<!-- what NxCard writes, roughly -->
<td class="bg-card nx-dark-bg-card text-card-foreground nx-dark-text-card-foreground ...">
Override a dark value the same way you override a light one — the same
theme option, one more -dark token:
ui({ brand: { name: 'Acme' }, theme: { 'color-background-dark': '#0b1220' } });
color-primary/color-primary-foreground, color-muted/
color-muted-foreground and color-info/color-info-foreground have no dark
value by default — a project that never sets their -dark twins reaches dark
mode with the light value unchanged, exactly as before these tokens existed.
Set the primary pair when a near-black brand primary would otherwise melt into
the dark card — its tints (bg-primary-15, border-primary-50, …) follow it
too — the muted pair when a dark card needs its own step above it, and the
info pair when NxLink's default (2.63:1 on the light card, 6.78:1 on the
dark one) needs a value that clears WCAG AA's 4.5:1 on both sides, which no
single color-info can:
ui({
brand: { name: 'Acme' },
theme: {
'color-primary': '#27272a',
'color-primary-dark': '#a1a1aa',
'color-primary-foreground-dark': '#18181b',
'color-muted-dark': '#1e293b',
'color-muted-foreground-dark': '#e2e8f0',
'color-info-dark': '#93c5fd', // 9.89:1 on the default color-card-dark
'color-info-foreground-dark': '#0c1e3d',
},
});
A dark logo disappears on a dark background: give brand.logo.darkSrc (or
<NxFigure :dark-src>) a light-background version, and it shows instead
under dark mode.
ui({
brand: {
name: 'Acme',
logo: {
src: 'https://acme.example/logo-dark-on-transparent.png',
darkSrc: 'https://acme.example/logo-light-on-transparent.png',
},
},
});
Gmail cannot be targeted from CSS and always shows the light styles. See Dark mode for the technique, per client, and its limits.
The light version is above; components-en.png is the same e-mail without dark mode forced.
Right-to-left languages
NxLayout writes dir on <html>, the body and the wrapper table, from
@nxgt/mail-i18n's dir global (or, without it, a small built-in list of
right-to-left scripts by locale). Seventeen other components mirror their
physical CSS for the direction they build in — an alert's accent bar, a
delta's and a see-also's arrow, a timeline's side, a list tile's trailing
slot, an entity header's actions, a summary row's alignment, and more (see
Right-to-left languages for the full list):
i18n({ locales: ['en', 'ar'] }); // dir is 'rtl' for ar, 'ltr' for en
<!-- what NxAlert writes, roughly, for an rtl build -->
<td class="border-0 border-solid p-4 border-r-8 ...">
@nxgt/mail-ui's own shared messages ship en/fr only; add a right-to-left
locale's translation of the common.* keys you use, the same way you add its
templates.
The shared messages — uiCatalogues
i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] });
common.greeting (Hello {name},), common.footer.why,
common.footer.ignore, common.avatar-group.more, common.timeline.empty,
common.metrics.of-target, common.metrics.this-period,
common.metrics.last-period, common.see-also, common.count-badge.label,
and the words of the details components (common.attributes,
common.postal-address, common.opening-hours.*, common.contacts.*,
common.file-list.*, common.rating.star), in en and fr. Your
locales/<locale>.json
overrides any of them, key by key:
// locales/en.json
{ "common": { "greeting": "Hi {name}," } }
See Shared messages.
Editor and type checking
With the Setup above, Vue's language tools (the Vue - Official
extension in the editor, vue-tsc in CI) know brand in every template, and
Maizzle's .maizzle/prefixed-components.d.ts gives them the Nx* components
and their props:
<!-- emails/welcome.vue -->
<template>
<NxTypography>Welcome to {{ brand.nam }}</NxTypography>
<!-- Property 'nam' does not exist on type 'Brand'. Did you mean 'name'? -->
</template>
bun add -d vue-tsc
bunx vue-tsc --noEmit # after maizzle prepare, as in CI
t, locale and placeholder are typed by @nxgt/mail-i18n, which writes
its own file beside this one. See
brand in templates, and
Property 'brand' does not exist
if the editor does not know brand.
Replacing a component
<!-- components/nx-badge.vue — replaces the package's <NxBadge> in every template -->
<template>
<span class="rounded-sm bg-primary px-2 text-xs text-primary-foreground"><slot /></span>
</template>
components/NxBadge.vue works as well: Maizzle names a component the same
from either case.
A file in your components/ named as one of our tags wins over ours — in
your templates, in ours, and in a package's. A copy of ours must be renamed:
ours are named without the prefix ui() adds, so a copied badge.vue is
<Badge> and replaces nothing until it is nx-badge.vue. See
The plugin and
A project's own component does not replace the package's.
Traps
List ui() in plugins. A component used without it fails the build:
NxLayout: ui() is not in the plugins of defineMailConfig.
A tag that resolves to no component fails the build, nested or not,
naming the tag and the file:
ui: <NxButon> in emails/welcome.vue is no component — check its name, or add the plugin or the components folder that brings it. Correct the tag, or
bring its component. It fails under NODE_ENV=production too.
With @nxgt/mail-i18n, give it the shared messages. <NxLayout> calls
t('common.footer.why') once the i18n plugin is listed, and the build fails
with calls t('common.footer.why'), which is not a key of the catalogues
unless catalogues: [uiCatalogues] is passed, or your catalogues hold the
key.
Another locale writes the common keys itself. uiCatalogues has en
and fr only: a project in de adds its ten keys — common.greeting,
common.footer.why, common.footer.ignore, common.avatar-group.more,
common.timeline.empty, common.metrics.of-target,
common.metrics.this-period, common.metrics.last-period and
common.see-also and common.count-badge.label — to locales/de.json.
Icons are slots. An e-mail has no icon font: pass an <img> with an
absolute URL, or a character, to #icon — or to NxIconButton's icon.
Dark mode is followed, not opted into. The layout always declares
<meta name="color-scheme" content="light dark">: a component that colours
by a token needing a dark value carries the matching class, and shows it in
every client that reads prefers-color-scheme or [data-ogsc]/[data-ogsb]
— Gmail excepted, which always shows the light styles. See
Dark mode.
Right-to-left is followed too, not opted into. dir on <html>, the
body and the wrapper table, and every component's physical CSS, follow the
locale's direction automatically — nothing to pass. See
Right-to-left languages.
Use NxSpacer for vertical space: <NxSpacer size="lg" />, on
Maizzle's <Spacer>, which Outlook keeps.
A count, a query or an icon is known at build time. NxCountBadge's
count, NxHighlightText's query and NxIconButton's icon, like
NxProgress's modelValue, fail the build when given a placeholder. Write
a count known only at send time in an NxBadge, highlight a query known at
build time, and put an icon known only at send time in NxIconButton's
default slot, as an <img :src="placeholder('iconUrl')">.
So are NxRating's modelValue and NxEventChip's color (a colour of
the theme or a hex one): write a rating known only at send time as text.
Never branch on a placeholder in a component or a template:
v-if="link.startsWith('https:')" is decided on the string {{ link }}.
Type safety, counted
9 plausible mistakes, 9 refused at compile time, each measured by a
@ts-expect-error in
test/types/refusals.ts
that fails the typecheck the moment it stops holding:
ui()without abrand.- A
brandgiven as its name alone, rather than{ name }. - A
logogiven as its URL alone, rather than{ src }. - A logo
widththat is not a number of pixels ('96px'). - A
themevalue that is not a CSS string ({ 'radius-lg': 4 }). - A template that assigns
brand. - A
logowithout itssrc. - A
brand.urlthat is not a string. - A logo
darkSrcthat is not a string.
The same file holds the calls that must keep compiling: a refusal that refuses the correct call is a bug.
2 template mistakes, 2 refused by .maizzle/nxgt-mail-ui.d.ts, each
measured by a @vue-expect-error in
test/fixture/types/refusals.vue,
checked by vue-tsc after maizzle prepare (bun run typecheck:templates):
- A field the brand does not have (
brand.nmae). brand.logo.srcwithout?.: the logo is optional.
A theme token is a string: whether theme.css declares it is checked when
ui() is called. A component's props are not in this count: Maizzle declares
the components in .maizzle/prefixed-components.d.ts, and Vue's language
tools check them in the editor.
Documentation
- The guides — one page per area, with every option, component and error.
- Troubleshooting — an error message, its cause and its fix.
- Roadmap — what is next, and what is deliberately not planned.
- Vocabulary — the words these pages use, defined once.
Licence
MIT
Related Packages
Ready transactional e-mails for a Maizzle project built with @nxgt/mail-ui and @...
An i18n plugin for a Maizzle project: ICU catalogues checked at build time, t()...
Version History
| Version | Released | License |
|---|---|---|
| 1.0.1 | MIT | |
| 1.0.0 | MIT | |
| 0.7.1 | MIT | |
| 0.7.0 | MIT | |
| 0.6.0 | MIT | |
| 0.5.0 | MIT | |
| 0.4.0 | MIT | |
| 0.3.0 | MIT | |
| 0.2.0 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |