Zum Inhalt springen
UI Kit
Accent

E-Mail-Mockup

E-Mails als Bilder für deine Seite. Ein Newsletter im Mailfenster, der Posteingang, in dem er ankommt, eine Bestellbestätigung, die Mail auf dem Telefon und eine Willkommensreihe. Aus JSON-Blöcken gebaut, so zeigt das Mockup deine echte Mail.

Eine von 3 Components in Kampagnen-Mockups

5 Variants

Variants

Newsletter

Ein geöffneter Newsletter im Mailfenster mit Absender und Betreff. Die lange Mail wandert beim Vorbeiscrollen im Fenster nach oben.

Im Studio öffnen

Ankunft im Posteingang

Eine Mailliste, in der deine Nachricht oben hereingleitet, ungelesen und als neu markiert, sobald die Liste im Bild ist. Die anderen machen Platz.

Im Studio öffnen

Bestellbestätigung

Ein Beleg mit einem Haken, der hereinfedert, einer Lieferleiste, die sich bis zum aktuellen Schritt füllt, Artikeln mit Bild, Summen und der nächsten Aktion.

Im Studio öffnen

Auf dem Telefon

Die Mail, wie die meisten sie lesen. Erst fällt eine Mitteilung herein, die Mail darunter scrollt mit Finger oder Rad.

Im Studio öffnen

Willkommensreihe

Drei Mails einer Reihe als Karten mit Tag und Öffnungsrate. Sie steigen nacheinander auf und fächern leicht, Hover richtet eine auf und hebt sie an.

Im Studio öffnen

Details

Was E-Mail-Mockup mitbringt, aus Registry und Quellcode gelesen.

Bereich
Visuals
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/email-mockup.tsx
Bringt mit
utilskiticonmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 13 eigene Texte

Props

Die Schnittstelle EmailMockupProps aus components/ui/email-mockup.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    EmailMockupVariant

    inboxnewslettertransactionalphonesequence

  • senderPflicht
    { name: string; address: string; logo?: React.ReactNode }
  • subjectPflicht
    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

Eingebaute Texte

Diese Texte schreibt E-Mail-Mockup selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 13 Texte anzeigen
inboxEnglisch InboxDeutsch Posteingang
toEnglisch to meDeutsch an mich
archiveEnglisch ArchiveDeutsch Archivieren
newEnglisch NewDeutsch Neu
unsubscribeEnglisch UnsubscribeDeutsch Abmelden
viewEnglisch View in browserDeutsch Im Browser ansehen
openedEnglisch openedDeutsch geöffnet
nowEnglisch nowDeutsch jetzt
mailEnglisch MailDeutsch Mail
searchEnglisch Search mailDeutsch Mails durchsuchen
dayEnglisch DayDeutsch Tag
starredEnglisch StarredDeutsch Markiert
sentEnglisch SentDeutsch Gesendet

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 459 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich E-Mail-Mockup?

Mit aiw add @ai-website/email-mockup. Der Befehl legt die Datei components/ui/email-mockup.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat E-Mail-Mockup?

5 Varianten, Newsletter, Ankunft im Posteingang, Bestellbestätigung, Auf dem Telefon und Willkommensreihe. Du wählst sie mit der Prop variant, zum Beispiel variant="inbox".

Kann ich die Texte von E-Mail-Mockup übersetzen?

Ja. E-Mail-Mockup bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Posteingang“, „an mich“ und „Archivieren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent E-Mail-Mockup einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini E-Mail-Mockup mit aiw_components und installieren sie mit aiw_add @ai-website/email-mockup. Nenn im Prompt die Variante, etwa variant="newsletter", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich E-Mail-Mockup ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden EmailMockup-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert