Skip to content
UI Kit
Accent color

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

5 variants

Variants

Newsletter

An open newsletter in a mail window with sender and subject. The long mail moves up inside the window while you scroll past it.

Open in Studio

Inbox arrival

A mail list where your message slides in on top, unread and marked new, once the list is in view. The others make room.

Open in Studio

Order confirmation

A receipt with a check that springs in, a delivery bar that fills to the current step, items with pictures, totals and the next action.

Open in Studio

On a phone

The mail as most people read it. A notification drops in first, the mail below scrolls with a finger or the wheel.

Open in Studio

Welcome series

Three mails of a series as cards with day and open rate. They rise one after another and fan out slightly, hover straightens and lifts one.

Open in Studio

Details

What Email mockup brings along, read from the registry and the source.

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

Props

The EmailMockupProps interface from components/ui/email-mockup.tsx, with the comments from the code.

  • variantoptional
    EmailMockupVariant

    inboxnewslettertransactionalphonesequence

  • senderrequired
    { name: string; address: string; logo?: React.ReactNode }
  • subjectrequired
    string
  • previewoptional
    string
  • timeoptional
    string
  • blocksoptional
    EmailBlock[]

    newsletter and phone: the mail body

  • mediaoptional
    React.ReactNode[]

    Pictures used by image and products blocks, by index

  • footeroptional
    string

    The 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
inboxEnglish InboxGerman Posteingang
toEnglish to meGerman an mich
archiveEnglish ArchiveGerman Archivieren
newEnglish NewGerman Neu
unsubscribeEnglish UnsubscribeGerman Abmelden
viewEnglish View in browserGerman Im Browser ansehen
openedEnglish openedGerman geöffnet
nowEnglish nowGerman jetzt
mailEnglish MailGerman Mail
searchEnglish Search mailGerman Mails durchsuchen
dayEnglish DayGerman Tag
starredEnglish StarredGerman Markiert
sentEnglish SentGerman 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
components/ui/email-mockup.tsx
'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">&lt;{sender.address}&gt;</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.

Often searched

Jump to

Copied to the clipboard