Email mockup
Emails as pictures for your page. A newsletter in a mail window, the inbox where it arrives, an order confirmation, the mail on a phone and a welcome series. Built from JSON blocks, so the mockup shows your real mail.
One of 3 components in Campaign mockups
Variants
Details
What Email mockup brings along, read from the registry and the source.
- Category
- Campaign mockups
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/email-mockup.tsx
- Brings along
- utilskiticonmotion
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The EmailMockupProps interface from components/ui/email-mockup.tsx, with the comments from the code.
variantoptionalEmailMockupVariantinboxnewslettertransactionalphonesequence
senderrequired{ name: string; address: string; logo?: React.ReactNode }subjectrequiredstringpreviewoptionalstringtimeoptionalstringblocksoptionalEmailBlock[]newsletter and phone: the mail body
mediaoptionalReact.ReactNode[]Pictures used by image and products blocks, by index
footeroptionalstringThe line at the very bottom of the mail, e.g. the address
messagesoptional{ from: string; subject: string; preview: string; time: string; unread?: boolean }[]inbox: the other mails in the list, below yours
orderoptional{ number: string; items: { name: string; meta?: string; price: string; media?: number }[]; lines: { label: string; value: string }[]; total: { label: string; value: string }; steps: string[]; step: number; action: string }transactional
seriesoptional{ day: string; subject: string; preview: string; rate?: string }[]sequence
Built-in texts
Email mockup writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 13 texts
| Key | English | German |
|---|---|---|
| inbox | English Inbox | German Posteingang |
| to | English to me | German an mich |
| archive | English Archive | German Archivieren |
| new | English New | German Neu |
| unsubscribe | English Unsubscribe | German Abmelden |
| view | English View in browser | German Im Browser ansehen |
| opened | English opened | German geöffnet |
| now | English now | German jetzt |
| English Mail | German Mail | |
| search | English Search mail | German Mails durchsuchen |
| day | English Day | German Tag |
| starred | English Starred | German Markiert |
| sent | English Sent | German Gesendet |
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 459 lines
'use client';
import { useEffect, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollProgress } from '@/components/ui/motion';
/*
* Emails as pictures for a page: a newsletter, a receipt or a welcome series,
* the way they arrive. Generic mail windows, no mail provider's look.
*
* inbox a mail list where your message arrives on top, unread, as it scrolls into view
* newsletter an open newsletter in a mail window, the long mail scrolls inside while the page scrolls
* transactional an order confirmation with items, totals and a delivery bar
* phone the mail on a phone, a notification drops in first
* sequence a welcome series as fanned cards with day and open rate
*
* The body is built from JSON blocks (heading, text, image, button, products,
* quote, divider), pictures come in as slots by index.
*/
export type EmailMockupVariant = 'inbox' | 'newsletter' | 'transactional' | 'phone' | 'sequence';
export type EmailBlock =
| { type: 'heading'; text: string }
| { type: 'text'; text: string }
| { type: 'image'; media: number; caption?: string }
| { type: 'button'; label: string }
| { type: 'products'; items: { name: string; price: string; media: number }[] }
| { type: 'quote'; text: string; author: string }
| { type: 'divider' };
export interface EmailMockupProps {
variant?: EmailMockupVariant;
sender: { name: string; address: string; logo?: React.ReactNode };
subject: string;
preview?: string;
time?: string;
/** newsletter and phone: the mail body */
blocks?: EmailBlock[];
/** Pictures used by image and products blocks, by index */
media?: React.ReactNode[];
/** The line at the very bottom of the mail, e.g. the address */
footer?: string;
/** inbox: the other mails in the list, below yours */
messages?: { from: string; subject: string; preview: string; time: string; unread?: boolean }[];
/** transactional */
order?: { number: string; items: { name: string; meta?: string; price: string; media?: number }[]; lines: { label: string; value: string }[]; total: { label: string; value: string }; steps: string[]; step: number; action: string };
/** sequence */
series?: { day: string; subject: string; preview: string; rate?: string }[];
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: { inbox: 'Inbox', to: 'to me', archive: 'Archive', new: 'New', unsubscribe: 'Unsubscribe', view: 'View in browser', opened: 'opened', now: 'now', mail: 'Mail', search: 'Search mail', day: 'Day', starred: 'Starred', sent: 'Sent' },
de: { inbox: 'Posteingang', to: 'an mich', archive: 'Archivieren', new: 'Neu', unsubscribe: 'Abmelden', view: 'Im Browser ansehen', opened: 'geöffnet', now: 'jetzt', mail: 'Mail', search: 'Mails durchsuchen', day: 'Tag', starred: 'Markiert', sent: 'Gesendet' },
};
type Texts = (typeof TEXTS)['en'];
const initials = (s: string) =>
s
.split(' ')
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
function SenderMark({ sender, size = 36 }: { sender: EmailMockupProps['sender']; size?: number }) {
return (
<span className="grid shrink-0 place-items-center overflow-hidden rounded-full bg-primary text-[12px] font-semibold text-primary-foreground [&>*]:size-full" style={{ width: size, height: size }}>
{sender.logo ?? initials(sender.name)}
</span>
);
}
/** The mail itself, as the recipient reads it */
function Body({ sender, blocks = [], media = [], footer, t, compact }: { sender: EmailMockupProps['sender']; blocks?: EmailBlock[]; media?: React.ReactNode[]; footer?: string; t: Texts; compact?: boolean }) {
const pad = compact ? 'px-5' : 'px-6 sm:px-10';
return (
<div className="mx-auto w-full max-w-[36rem] bg-card text-card-foreground">
<div className={cn('flex items-center justify-between py-5', pad)}>
<span className="flex items-center gap-2.5">
<SenderMark sender={sender} size={28} />
<span className="text-[15px] font-semibold tracking-[-0.01em]">{sender.name}</span>
</span>
<span className="text-[11px] text-subtle-foreground underline underline-offset-2">{t.view}</span>
</div>
<div className={cn('grid gap-4 pb-8', pad)}>
{blocks.map((b, i) => {
switch (b.type) {
case 'heading':
return (
<h3 key={i} className={cn('text-balance font-semibold leading-tight tracking-[-0.02em] text-foreground', compact ? 'text-[22px]' : 'text-[clamp(1.5rem,1.2rem+1vw,1.875rem)]')}>
{b.text}
</h3>
);
case 'text':
return (
<p key={i} className="text-pretty text-[15px] leading-relaxed text-muted-foreground">
{b.text}
</p>
);
case 'image':
return (
<figure key={i} className={cn(compact ? '-mx-5' : '-mx-6 sm:mx-0')}>
<div className={cn('relative aspect-[16/9] overflow-hidden bg-muted [&>*]:size-full', !compact && 'sm:rounded-lg')}>{media[b.media]}</div>
{b.caption && <figcaption className={cn('mt-2 text-[12px] text-subtle-foreground', compact ? 'px-5' : 'px-6 sm:px-0')}>{b.caption}</figcaption>}
</figure>
);
case 'button':
return (
<span key={i} className="inline-flex h-11 w-fit items-center justify-center rounded-lg bg-primary px-6 text-[15px] font-semibold text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18)] max-sm:w-full">
{b.label}
</span>
);
case 'products':
return (
<div key={i} className="grid grid-cols-2 gap-3">
{b.items.map((p) => (
<div key={p.name} className="overflow-hidden rounded-lg border border-border">
<div className="aspect-square bg-muted [&>*]:size-full">{media[p.media]}</div>
<div className="px-3 py-2.5">
<p className="truncate text-[13px] font-medium text-foreground">{p.name}</p>
<p className="text-[13px] tabular-nums text-muted-foreground">{p.price}</p>
</div>
</div>
))}
</div>
);
case 'quote':
return (
<blockquote key={i} className="border-l-2 border-primary pl-4">
<p className="text-pretty text-[16px] font-medium leading-relaxed text-foreground">{b.text}</p>
<footer className="mt-2 text-[13px] text-muted-foreground">{b.author}</footer>
</blockquote>
);
default:
return <hr key={i} className="border-border" />;
}
})}
</div>
{footer && (
<div className={cn('border-t border-border py-6 text-center text-[12px] leading-relaxed text-subtle-foreground', pad)}>
<p>{footer}</p>
<p className="mt-1 underline underline-offset-2">{t.unsubscribe}</p>
</div>
)}
</div>
);
}
/** The top of a generic mail window */
function WindowBar({ title, t }: { title: string; t: Texts }) {
return (
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-4">
<span className="flex gap-1.5" aria-hidden>
{[0, 1, 2].map((i) => (
<span key={i} className="size-2.5 rounded-full bg-border-strong" />
))}
</span>
<span className="min-w-0 flex-1 truncate text-center text-[12px] font-medium text-muted-foreground">{title}</span>
<span className="flex gap-1 text-subtle-foreground" aria-hidden>
{(['archive', 'corner-down-left', 'share-out'] as IconName[]).map((n) => (
<span key={n} className="grid size-7 place-items-center">
<Icon name={n} size={14} />
</span>
))}
</span>
<span className="sr-only">{t.mail}</span>
</div>
);
}
function MailHead({ sender, subject, time, t }: { sender: EmailMockupProps['sender']; subject: string; time?: string; t: Texts }) {
return (
<div className="border-b border-border px-5 py-4 sm:px-6">
<p className="text-balance text-[17px] font-semibold leading-snug tracking-[-0.01em] text-foreground">{subject}</p>
<div className="mt-3 flex items-center gap-3">
<SenderMark sender={sender} />
<div className="min-w-0 flex-1 leading-tight">
<p data-clip-ok className="truncate text-[13px] font-semibold text-foreground">
{sender.name} <span className="font-normal text-muted-foreground"><{sender.address}></span>
</p>
<p className="text-[12px] text-muted-foreground">{t.to}</p>
</div>
{time && <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{time}</span>}
</div>
</div>
);
}
const frame = 'overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]';
/* ------------------------------------------------------------------ */
/**
* An email as a picture.
* <EmailMockup variant="newsletter" sender={{ name: 'Café Mira', address: 'hello@cafemira.de' }} subject="…" blocks={[…]} media={[<img … />]} />
*/
export function EmailMockup(props: EmailMockupProps) {
const { variant = 'newsletter', sender, subject, preview, time, blocks, media, footer, messages = [], order, series = [], labels, className } = props;
const t = useTexts(TEXTS, labels);
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
// The mail scrolls through while the frame passes the middle of the window, done before the page can end
const scroll = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.45 });
const [arrived, setArrived] = useState(true);
// phone: the notification slides away again after a few seconds, like on a real phone
const [banner, setBanner] = useState(true);
useEffect(() => {
// The new mail arrives after the list is in view, without JS it simply sits on top
if (variant !== 'inbox' && variant !== 'phone') return;
if (prefersReducedMotion()) {
// Without motion the notification still leaves after a while, it just does not slide
if (variant !== 'phone' || !inView) return;
const away = window.setTimeout(() => setBanner(false), 4200);
return () => window.clearTimeout(away);
}
if (!inView) {
setArrived(false);
return;
}
const id = window.setTimeout(() => setArrived(true), 500);
const away = variant === 'phone' ? window.setTimeout(() => setBanner(false), 4200) : 0;
return () => {
window.clearTimeout(id);
window.clearTimeout(away);
};
}, [inView, variant]);
/* ---------- inbox ---------- */
if (variant === 'inbox') {
const rows = [{ from: sender.name, subject, preview: preview ?? '', time: time ?? t.now, unread: true, mine: true }, ...messages.map((m) => ({ ...m, mine: false }))];
return (
<div ref={ref} className={cn(frame, className)}>
<WindowBar title={t.inbox} t={t} />
<div className="grid md:grid-cols-[11rem_minmax(0,1fr)]">
<nav aria-hidden className="border-r border-border bg-surface p-3 max-md:hidden">
{[['inbox', t.inbox, rows.filter((r) => r.unread).length], ['star', t.starred, 0], ['send', t.sent, 0], ['archive', t.archive, 0]].map(([icon, label, n], i) => (
<span key={label as string} className={cn('flex h-9 items-center gap-2.5 rounded-md px-2.5 text-[13px]', i === 0 ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground')}>
<Icon name={icon as IconName} size={15} />
<span className="flex-1">{label}</span>
{(n as number) > 0 && <span className="text-[12px] font-semibold tabular-nums">{n}</span>}
</span>
))}
</nav>
<ul className="min-w-0">
<li aria-hidden className="flex h-11 items-center gap-2 border-b border-border px-4 text-[13px] text-subtle-foreground">
<Icon name="search" size={14} />
{t.search}
</li>
{rows.map((r, i) => (
<li
key={`${r.from}${i}`}
className={cn(
'group/row relative flex gap-3 border-b border-border px-4 py-3 transition-[transform,opacity,background-color] duration-700 ease-[var(--ease-kit)] last:border-b-0 hover:bg-accent/50 motion-reduce:transition-none',
r.mine && 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)]',
r.mine && !arrived && '-translate-y-3 opacity-0',
!r.mine && !arrived && '-translate-y-[4.25rem]',
)}
>
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', r.unread ? 'bg-primary' : 'bg-transparent')} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className={cn('min-w-0 flex-1 truncate text-[14px]', r.unread ? 'font-semibold text-foreground' : 'text-foreground')}>{r.from}</span>
<span className={cn('shrink-0 text-[12px] tabular-nums', r.mine ? 'font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-subtle-foreground')}>{r.time}</span>
</span>
<span className={cn('block truncate text-[13px]', r.unread ? 'font-medium text-foreground' : 'text-muted-foreground')}>{r.subject}</span>
<span className="block truncate text-[12px] text-muted-foreground">{r.preview}</span>
</span>
{r.mine && arrived && (
<span className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground kit-rise max-sm:hidden" style={{ '--kit-delay': '300ms' } as React.CSSProperties}>
{t.new}
</span>
)}
</li>
))}
</ul>
</div>
</div>
);
}
/* ---------- newsletter ---------- */
if (variant === 'newsletter') {
return (
<div ref={scroll} className={cn(frame, className)}>
<WindowBar title={subject} t={t} />
<MailHead sender={sender} subject={subject} time={time} t={t} />
{/* The long mail moves up inside the window while the page scrolls */}
<div data-clip-ok className="relative h-[34rem] overflow-hidden bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] sm:h-[38rem]">
<div className="pt-6 will-change-transform motion-reduce:!transform-none sm:px-6" style={{ transform: 'translateY(calc(var(--kit-progress, 0) * -1 * var(--mail-travel, 0px)))', '--mail-travel': '22rem' } as React.CSSProperties}>
<div className="mx-auto max-w-[36rem] overflow-hidden border-border sm:rounded-xl sm:border sm:shadow-[var(--shadow-md)]">
<Body sender={sender} blocks={blocks} media={media} footer={footer} t={t} />
</div>
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] to-transparent" />
</div>
</div>
);
}
/* ---------- phone ---------- */
if (variant === 'phone') {
return (
<div ref={ref} className={cn('relative mx-auto w-full max-w-[20rem]', className)}>
<div className="relative overflow-hidden rounded-[2.75rem] border-[10px] border-bezel bg-background shadow-[var(--shadow-lg)]">
<div className="flex h-9 items-center justify-between px-6 text-[12px] font-semibold text-foreground">
<span>8:30</span>
<span className="h-[18px] w-[72px] rounded-full bg-bezel" aria-hidden />
<span className="flex items-center gap-1" aria-hidden>
<Icon name="wifi" size={13} />
<span className="h-2.5 w-5 rounded-[3px] border border-current p-px">
<span className="block h-full w-3/4 rounded-[1px] bg-current" />
</span>
</span>
</div>
<div className="flex items-center justify-between border-b border-border px-4 pb-2.5 pt-1">
<span className="flex items-center gap-1 text-[13px] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]">
<Icon name="chevron-left" size={16} />
{t.inbox}
</span>
<span className="flex gap-3 text-muted-foreground" aria-hidden>
<Icon name="archive" size={16} />
<Icon name="corner-down-left" size={16} />
</span>
</div>
<div className="kit-no-scrollbar h-[30rem] overflow-y-auto">
<div className="px-4 pb-3 pt-3">
<p className="text-[16px] font-semibold leading-snug text-foreground">{subject}</p>
<p className="mt-1.5 text-[12px] text-muted-foreground">
{sender.name} · {time}
</p>
</div>
<Body sender={sender} blocks={blocks} media={media} footer={footer} t={t} compact />
</div>
{/* The notification that announces the mail */}
<div className={cn('absolute inset-x-2 top-11 z-10 flex gap-2.5 rounded-2xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-3 shadow-[var(--shadow-lg)] backdrop-blur-md transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', arrived && banner ? 'translate-y-0 opacity-100' : '-translate-y-24 opacity-0')}>
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground">
<Icon name="mail" size={18} />
</span>
<span className="min-w-0 flex-1 leading-tight">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-semibold text-foreground">{sender.name}</span>
<span className="shrink-0 text-[11px] text-subtle-foreground">{t.now}</span>
</span>
<span className="block truncate text-[12px] font-medium text-foreground">{subject}</span>
<span className="block truncate text-[12px] text-muted-foreground">{preview}</span>
</span>
</div>
</div>
</div>
);
}
/* ---------- transactional ---------- */
if (variant === 'transactional' && order) {
return (
<div ref={ref} className={cn(frame, className)}>
<WindowBar title={subject} t={t} />
<MailHead sender={sender} subject={subject} time={time} t={t} />
<div className="bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] py-6 sm:px-6 sm:py-8">
<div className="mx-auto max-w-[34rem] overflow-hidden bg-card sm:rounded-xl sm:border sm:border-border sm:shadow-[var(--shadow-md)]">
<div className="px-6 pb-2 pt-7 sm:px-8">
<span className={cn('grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground transition-transform duration-700 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', !inView && 'scale-50')}>
<Icon name="check" size={24} strokeWidth={2.2} />
</span>
<p className="mt-5 text-[13px] tabular-nums text-muted-foreground">{order.number}</p>
{preview && <p className="mt-1 text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{preview}</p>}
</div>
{/* Delivery steps, the bar fills up to the current one */}
<div className="px-6 py-5 sm:px-8">
<div className="relative h-1.5 overflow-hidden rounded-full bg-track">
<span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? (order.step + 0.5) / order.steps.length : 0.05})` }} />
</div>
<ol className="mt-2.5 grid text-[11px]" style={{ gridTemplateColumns: `repeat(${order.steps.length}, minmax(0, 1fr))` }}>
{order.steps.map((s, i) => (
<li key={s} className={cn('truncate', i === order.steps.length - 1 && 'text-right', i > 0 && i < order.steps.length - 1 && 'text-center', i <= order.step ? 'font-medium text-foreground' : 'text-subtle-foreground')}>
{s}
</li>
))}
</ol>
</div>
<ul className="border-y border-border px-6 sm:px-8">
{order.items.map((it) => (
<li key={it.name} className="flex items-center gap-3 border-b border-border py-3.5 last:border-b-0">
{it.media !== undefined && <span className="size-12 shrink-0 overflow-hidden rounded-lg bg-muted [&>*]:size-full">{media?.[it.media]}</span>}
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-foreground">{it.name}</span>
{it.meta && <span className="block truncate text-[12px] text-muted-foreground">{it.meta}</span>}
</span>
<span className="shrink-0 text-[14px] tabular-nums text-foreground">{it.price}</span>
</li>
))}
</ul>
<dl className="grid gap-2 px-6 py-5 text-[14px] sm:px-8">
{order.lines.map((l) => (
<div key={l.label} className="flex justify-between gap-4 text-muted-foreground">
<dt>{l.label}</dt>
<dd className="tabular-nums">{l.value}</dd>
</div>
))}
<div className="mt-1 flex justify-between gap-4 border-t border-border pt-3 text-[16px] font-semibold text-foreground">
<dt>{order.total.label}</dt>
<dd className="tabular-nums">{order.total.value}</dd>
</div>
</dl>
<div className="px-6 pb-7 sm:px-8">
<span className="flex h-11 items-center justify-center rounded-lg bg-primary text-[15px] font-semibold text-primary-foreground">{order.action}</span>
</div>
{footer && <p className="border-t border-border px-6 py-5 text-center text-[12px] text-subtle-foreground sm:px-8">{footer}</p>}
</div>
</div>
</div>
);
}
/* ---------- sequence ---------- */
return (
<div ref={ref} className={cn('relative', className)}>
<ol className="grid grid-cols-1 gap-4 sm:mx-auto sm:max-w-[30rem] lg:max-w-none lg:grid-cols-3 lg:gap-5">
{series.map((s, i) => (
<li
key={s.day + s.subject}
className={cn(
'group/card relative transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none',
!inView && 'translate-y-6 opacity-0',
inView && i === 0 && 'lg:-rotate-2',
inView && i === 2 && 'lg:rotate-2',
'lg:hover:z-10 lg:hover:-translate-y-2 lg:hover:rotate-0',
)}
style={{ transitionDelay: inView ? `${i * 120}ms` : '0ms' }}
>
<div className="mb-3 flex items-center gap-2">
<span className="grid h-7 min-w-7 place-items-center rounded-full bg-primary px-2 text-[12px] font-semibold tabular-nums text-primary-foreground">{s.day}</span>
{i < series.length - 1 && <span aria-hidden className="h-px flex-1 bg-[linear-gradient(90deg,var(--border-strong),transparent)] max-lg:hidden" />}
</div>
<div className={cn(frame, 'shadow-[var(--highlight),var(--shadow-md)] transition-shadow duration-500 group-hover/card:shadow-[var(--highlight),var(--shadow-lg)]')}>
<div className="flex items-center gap-2.5 border-b border-border px-4 py-3">
<SenderMark sender={sender} size={26} />
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">{sender.name}</span>
{s.rate && (
<span className="shrink-0 rounded-full bg-[color-mix(in_oklab,var(--success)_15%,transparent)] px-2 py-0.5 text-[11px] font-medium tabular-nums text-success-foreground">
{s.rate} {t.opened}
</span>
)}
</div>
<div className="px-4 py-4">
<p className="text-pretty text-[15px] font-semibold leading-snug text-foreground">{s.subject}</p>
<p className="mt-2 line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">{s.preview}</p>
{media?.[i] && <div data-clip-ok className="mt-4 aspect-[16/9] overflow-hidden rounded-lg bg-muted [&>*]:size-full">{media[i]}</div>}
</div>
</div>
</li>
))}
</ol>
</div>
);
}Frequent questions
How do I install Email mockup?
With aiw add @ai-website/email-mockup. The command puts components/ui/email-mockup.tsx into your project, the code is yours from then on. It brings along icon and motion. It needs no further npm packages.
Which variants does Email mockup have?
5 variants, Newsletter, Inbox arrival, Order confirmation, On a phone and Welcome series. You pick one with the variant prop, for example variant="inbox".
Can I translate the texts of Email mockup?
Yes. Email mockup carries 13 texts of its own in English and German, such as “Inbox”, “to me” and “Archive”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Email mockup?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Email mockup with aiw_components and install it with aiw_add @ai-website/email-mockup. Name the variant in your prompt, for example variant="newsletter", and the assistant writes your content straight into the props.
Can I adjust Email mockup without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching EmailMockup code to copy.