Email Composer New
A sales email composer with recipient chips, templates, highlighted merge fields, AI rewrites, scheduled send and open tracking.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/crm-email-composer.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/crm/EmailComposer.tsx
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { CalendarClock, Check, ChevronDown, Eye, FileText, LoaderCircle, MailOpen, PenLine, Send, Sparkles, Undo2, X } from 'lucide-react';
import { cn } from '@/lib/cn';
export type Recipient = { name: string; email: string };
export type EmailTemplate = { id: string; name: string; subject: string; body: string };
export type RewriteTone = 'shorter' | 'friendlier' | 'formal';
const TOKEN = /(\{\{\s*\w+\s*\}\})/g;
const IS_TOKEN = /^\{\{\s*\w+\s*\}\}$/;
const keyOf = (t: string) => t.replace(/[{}\s]/g, '');
function initials(name: string) {
return name
.split(' ')
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
/** Highlights {{merge_fields}} without changing text metrics, so it can sit under a textarea. */
function Highlighted({ text, data }: { text: string; data: Record<string, string> }) {
return (
<>
{text.split(TOKEN).map((part, i) =>
IS_TOKEN.test(part) ? (
<span key={i} className={cn('rounded-[4px]', data[keyOf(part)] ? 'bg-brand-500/20 text-brand-200' : 'bg-amber-500/20 text-amber-200')}>
{part}
</span>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
function Resolved({ text, data }: { text: string; data: Record<string, string> }) {
return (
<>
{text.split(TOKEN).map((part, i) => {
if (!IS_TOKEN.test(part)) return <span key={i}>{part}</span>;
const v = data[keyOf(part)];
return v ? (
<span key={i} className="text-ink-50 underline decoration-brand-400/50 decoration-dotted underline-offset-4">
{v}
</span>
) : (
<span key={i} className="rounded-[4px] bg-amber-500/15 px-1 text-amber-200">
missing {keyOf(part)}
</span>
);
})}
</>
);
}
function Chip({ r, onRemove }: { r: Recipient; onRemove?: () => void }) {
return (
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/[0.06] py-0.5 pl-0.5 pr-1.5 text-[12px] text-ink-100 ring-1 ring-white/10 animate-scale-in">
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-full bg-brand-500/20 text-[9px] font-semibold text-brand-200">{initials(r.name)}</span>
<span className="truncate">{r.name}</span>
{onRemove && (
<button onClick={onRemove} aria-label={`Remove ${r.name}`} className="focus-ring rounded-full text-ink-500 transition-colors hover:text-ink-100">
<X className="h-3 w-3" />
</button>
)}
</span>
);
}
function RecipientRow({ label, list, setList, autoFocus }: { label: string; list: Recipient[]; setList: (r: Recipient[]) => void; autoFocus?: boolean }) {
const [draft, setDraft] = useState('');
const [bad, setBad] = useState(false);
const add = () => {
const v = draft.trim().replace(/,$/, '');
if (!v) return;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
setBad(true);
return;
}
const name = v
.split('@')[0]
.split(/[._-]/)
.map((s) => s[0]?.toUpperCase() + s.slice(1))
.join(' ');
setList([...list, { name, email: v }]);
setDraft('');
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
if (draft.trim()) {
e.preventDefault();
add();
}
} else if (e.key === 'Backspace' && !draft && list.length) setList(list.slice(0, -1));
};
return (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="w-6 shrink-0 text-[12px] text-ink-500">{label}</span>
{list.map((r, i) => (
<Chip key={r.email} r={r} onRemove={() => setList(list.filter((_, j) => j !== i))} />
))}
<input
value={draft}
autoFocus={autoFocus}
onChange={(e) => {
setDraft(e.target.value);
setBad(false);
}}
onKeyDown={onKey}
onBlur={add}
aria-label={`${label} recipients`}
placeholder={list.length ? '' : 'name@company.com'}
className={cn('h-6 min-w-[120px] flex-1 bg-transparent text-[12.5px] text-ink-100 outline-none placeholder:text-ink-600', bad && 'text-rose-300')}
/>
</div>
);
}
function Toggle({ on, onChange, label, icon }: { on: boolean; onChange: (v: boolean) => void; label: string; icon: ReactNode }) {
return (
<button role="switch" aria-checked={on} onClick={() => onChange(!on)} className="focus-ring flex items-center gap-2 rounded-md px-1 text-[12px] text-ink-400 transition-colors hover:text-ink-100">
{icon}
<span className="hidden sm:inline">{label}</span>
<span className={cn('relative h-4 w-7 rounded-full transition-colors duration-300', on ? 'bg-brand-500' : 'bg-white/10')}>
<span className={cn('absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-all duration-300 ease-spring', on ? 'left-[14px]' : 'left-0.5')} />
</span>
</button>
);
}
const SCHEDULES = ['In 2 hours', 'Tomorrow, 9:00 AM', 'Monday, 8:30 AM'];
export type EmailComposerProps = {
to: Recipient[];
cc?: Recipient[];
templates: EmailTemplate[];
data: Record<string, string>;
/** Returns the rewritten body for a tone. */
rewrite?: (body: string, tone: RewriteTone, templateId: string) => string;
onSend?: (email: { to: Recipient[]; cc: Recipient[]; subject: string; body: string; scheduledFor?: string; track: boolean }) => void;
className?: string;
};
export function EmailComposer({ to: to0, cc: cc0 = [], templates, data, rewrite, onSend, className }: EmailComposerProps) {
const [to, setTo] = useState(to0);
const [cc, setCc] = useState(cc0);
const [showCc, setShowCc] = useState(cc0.length > 0);
const [tpl, setTpl] = useState(templates[0]);
const [subject, setSubject] = useState(templates[0].subject);
const [body, setBody] = useState(templates[0].body);
const [preview, setPreview] = useState(false);
const [menu, setMenu] = useState<'tpl' | 'when' | null>(null);
const [busy, setBusy] = useState<RewriteTone | null>(null);
const [undo, setUndo] = useState<string | null>(null);
const [when, setWhen] = useState<string | null>(null);
const [track, setTrack] = useState(true);
const [sent, setSent] = useState<'idle' | 'sending' | 'sent'>('idle');
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
useEffect(() => {
if (!menu) return;
const close = (e: PointerEvent) => {
if (!(e.target as Element).closest?.(`[data-menu-root="${menu}"]`)) setMenu(null);
};
const esc = (e: globalThis.KeyboardEvent) => e.key === 'Escape' && setMenu(null);
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', esc);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', esc);
};
}, [menu]);
const fields = [...new Set([...(subject + body).matchAll(TOKEN)].map((m) => keyOf(m[0])))];
const missing = fields.filter((f) => !data[f]);
const pick = (t: EmailTemplate) => {
setTpl(t);
setSubject(t.subject);
setBody(t.body);
setUndo(null);
setMenu(null);
};
const doRewrite = (tone: RewriteTone) => {
if (!rewrite || busy) return;
setBusy(tone);
timers.current.push(
window.setTimeout(() => {
setUndo(body);
setBody(rewrite(body, tone, tpl.id));
setBusy(null);
}, 900),
);
};
const send = () => {
if (!to.length || sent !== 'idle') return;
setSent('sending');
timers.current.push(
window.setTimeout(() => {
setSent('sent');
onSend?.({ to, cc, subject, body, scheduledFor: when ?? undefined, track });
}, 1100),
);
};
if (sent === 'sent')
return (
<div className={cn('w-full rounded-2xl bg-ink-900 p-6 text-center shadow-card animate-scale-in', className)}>
<span className="mx-auto grid h-11 w-11 place-items-center rounded-full bg-emerald-500/15 text-emerald-300">
<Check className="h-5 w-5" />
</span>
<p className="mt-3 text-sm font-medium text-ink-50">{when ? `Scheduled for ${when}` : 'Email sent'}</p>
<p className="mt-1 text-xs text-ink-400">
To {to.map((r) => r.name).join(', ')}
{track ? ' · tracking opens' : ''}
</p>
<button onClick={() => setSent('idle')} className="focus-ring mt-4 inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/5 px-3 text-xs text-ink-200 transition-colors hover:bg-white/10">
<PenLine className="h-3.5 w-3.5" /> New email
</button>
</div>
);
return (
<div className={cn('w-full overflow-visible rounded-2xl bg-ink-900 shadow-card', className)}>
<div className="space-y-2 border-b border-white/5 px-4 py-3">
<div className="flex items-start gap-2">
<RecipientRow label="To" list={to} setList={setTo} />
{!showCc && (
<button onClick={() => setShowCc(true)} className="focus-ring mt-0.5 shrink-0 rounded px-1 text-[12px] text-ink-500 hover:text-ink-200">
Cc
</button>
)}
</div>
{showCc && <RecipientRow label="Cc" list={cc} setList={setCc} autoFocus={cc.length === 0} />}
<div className="flex items-center gap-2 border-t border-white/5 pt-2">
<span className="w-6 shrink-0 text-[12px] text-ink-500">Subj</span>
{preview ? (
<p className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink-50">
<Resolved text={subject} data={data} />
</p>
) : (
<input value={subject} onChange={(e) => setSubject(e.target.value)} aria-label="Subject" className="min-w-0 flex-1 bg-transparent text-[13.5px] font-medium text-ink-50 outline-none" />
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 border-b border-white/5 px-3 py-2">
<div className="relative" data-menu-root="tpl">
<button onClick={() => setMenu(menu === 'tpl' ? null : 'tpl')} aria-haspopup="listbox" aria-expanded={menu === 'tpl'} className="focus-ring flex h-7 items-center gap-1.5 rounded-lg bg-white/[0.04] px-2.5 text-[12px] text-ink-200 ring-1 ring-white/10 transition-colors hover:bg-white/[0.07]">
<FileText className="h-3.5 w-3.5 text-ink-400" />
<span className="max-w-[140px] truncate">{tpl.name}</span>
<ChevronDown className={cn('h-3 w-3 text-ink-500 transition-transform', menu === 'tpl' && 'rotate-180')} />
</button>
{menu === 'tpl' && (
<ul role="listbox" aria-label="Templates" className="absolute left-0 top-[calc(100%+6px)] z-20 w-60 rounded-xl bg-ink-850 p-1 shadow-pop animate-scale-in">
{templates.map((t) => (
<li key={t.id}>
<button role="option" aria-selected={t.id === tpl.id} onClick={() => pick(t)} className={cn('focus-ring flex w-full items-start gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-white/5', t.id === tpl.id && 'bg-white/[0.04]')}>
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-500" />
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] text-ink-100">{t.name}</span>
<span className="block truncate text-[11px] text-ink-500">{t.subject}</span>
</span>
{t.id === tpl.id && <Check className="mt-0.5 h-3.5 w-3.5 text-brand-300" />}
</button>
</li>
))}
</ul>
)}
</div>
<span className="mx-1 hidden h-4 w-px bg-white/10 sm:block" />
<span className="flex items-center gap-1 text-[11.5px] text-ink-500">
<Sparkles className="h-3.5 w-3.5 text-brand-300" />
<span className="hidden sm:inline">Rewrite</span>
</span>
{(['shorter', 'friendlier', 'formal'] as RewriteTone[]).map((tone) => (
<button
key={tone}
onClick={() => doRewrite(tone)}
disabled={!!busy}
className={cn('focus-ring flex h-7 items-center gap-1 rounded-full px-2.5 text-[11.5px] capitalize ring-1 transition-all disabled:cursor-wait', busy === tone ? 'bg-brand-500/15 text-brand-200 ring-brand-500/30' : 'text-ink-300 ring-white/10 hover:bg-white/5 hover:text-ink-100')}
>
{busy === tone && <LoaderCircle className="h-3 w-3 animate-spin" />}
{tone}
</button>
))}
{undo !== null && !busy && (
<button
onClick={() => {
setBody(undo);
setUndo(null);
}}
className="focus-ring flex h-7 items-center gap-1 rounded-full px-2 text-[11.5px] text-ink-400 hover:text-ink-100 animate-fade-in"
>
<Undo2 className="h-3 w-3" /> Undo
</button>
)}
<button onClick={() => setPreview((p) => !p)} aria-pressed={preview} className={cn('focus-ring ml-auto flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12px] transition-colors', preview ? 'bg-brand-500/15 text-brand-200' : 'text-ink-400 hover:bg-white/5 hover:text-ink-100')}>
{preview ? <PenLine className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{preview ? 'Edit' : 'Preview'}
</button>
</div>
<div className={cn('relative px-4 py-3 transition-opacity duration-300', busy && 'opacity-50')}>
{busy && <div className="pointer-events-none absolute inset-0 animate-shimmer bg-[linear-gradient(110deg,transparent_30%,rgba(125,211,252,0.07)_50%,transparent_70%)] bg-[length:200%_100%]" />}
{preview ? (
<p className="min-h-[168px] whitespace-pre-wrap break-words text-[13.5px] leading-6 text-ink-300 animate-fade-in">
<Resolved text={body} data={data} />
</p>
) : (
<div className="relative min-h-[168px]">
<div aria-hidden className="pointer-events-none whitespace-pre-wrap break-words text-[13.5px] leading-6 text-ink-300">
<Highlighted text={body + '\n'} data={data} />
</div>
<textarea value={body} onChange={(e) => setBody(e.target.value)} aria-label="Email body" spellCheck={false} className="absolute inset-0 h-full w-full resize-none overflow-hidden bg-transparent text-[13.5px] leading-6 text-transparent caret-ink-100 outline-none selection:bg-brand-500/30" />
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-white/5 px-3 py-2.5">
<span className={cn('flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px]', missing.length ? 'bg-amber-500/10 text-amber-200' : 'bg-white/[0.04] text-ink-400')}>
<span className={cn('h-1.5 w-1.5 rounded-full', missing.length ? 'bg-amber-400' : 'bg-emerald-400')} />
{fields.length} fields · {missing.length ? `${missing.length} missing` : 'all resolved'}
</span>
<Toggle on={track} onChange={setTrack} label="Track opens" icon={<MailOpen className="h-3.5 w-3.5" />} />
{when && (
<span className="flex items-center gap-1 rounded-full bg-brand-500/10 px-2 py-1 text-[11px] text-brand-200 animate-scale-in">
<CalendarClock className="h-3 w-3" /> {when}
<button onClick={() => setWhen(null)} aria-label="Clear schedule" className="focus-ring rounded-full hover:text-white">
<X className="h-3 w-3" />
</button>
</span>
)}
<div className="relative ml-auto flex" data-menu-root="when">
<button onClick={send} disabled={!to.length || sent !== 'idle'} className="focus-ring flex h-8 items-center gap-1.5 rounded-l-lg bg-brand-500 pl-3 pr-2.5 text-[12.5px] font-medium text-white transition-colors hover:bg-brand-400 disabled:opacity-50">
{sent === 'sending' ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
{when ? 'Schedule' : 'Send'}
</button>
<button onClick={() => setMenu(menu === 'when' ? null : 'when')} aria-label="Schedule send" aria-haspopup="menu" aria-expanded={menu === 'when'} className="focus-ring grid h-8 w-8 place-items-center rounded-r-lg border-l border-white/20 bg-brand-500 text-white transition-colors hover:bg-brand-400">
<CalendarClock className="h-3.5 w-3.5" />
</button>
{menu === 'when' && (
<ul role="menu" className="absolute bottom-[calc(100%+6px)] right-0 z-20 w-48 rounded-xl bg-ink-850 p-1 shadow-pop animate-scale-in">
{SCHEDULES.map((s) => (
<li key={s}>
<button
role="menuitem"
onClick={() => {
setWhen(s);
setMenu(null);
}}
className="focus-ring flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-[12.5px] text-ink-200 hover:bg-white/5"
>
{s}
{when === s && <Check className="h-3.5 w-3.5 text-brand-300" />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
/* ——— demo ——— */
const DEMO_TEMPLATES: EmailTemplate[] = [
{
id: 'intro',
name: 'Follow-up after demo',
subject: 'Great meeting you, {{first_name}}',
body: 'Hi {{first_name}},\n\nThanks for walking us through how {{company}} runs its pipeline today. From what you shared, the {{plan}} plan covers your {{seats}} reps with room to grow.\n\nWould Thursday work to bring in your ops lead?\n\nBest,\n{{sender}}',
},
{
id: 'nudge',
name: 'Gentle nudge',
subject: 'Still thinking it over, {{first_name}}?',
body: 'Hi {{first_name}},\n\nJust floating this back up. Happy to send a short video of the workflow we discussed for {{company}}, or jump on a 15-minute call.\n\nCheers,\n{{sender}}',
},
{
id: 'renewal',
name: 'Renewal check-in',
subject: '{{company}} renewal on {{renewal_date}}',
body: 'Hi {{first_name}},\n\nYour {{plan}} plan renews on {{renewal_date}}. Before then, I would love 20 minutes to review what is working and what we can improve.\n\nBest,\n{{sender}}',
},
];
const REWRITES: Record<string, Record<RewriteTone, string>> = {
intro: {
shorter: 'Hi {{first_name}},\n\nThanks for the walkthrough. The {{plan}} plan fits your {{seats}} reps at {{company}}.\n\nThursday to loop in your ops lead?\n\n{{sender}}',
friendlier: 'Hey {{first_name}}!\n\nReally enjoyed seeing how {{company}} runs its pipeline. Honestly, the {{plan}} plan feels like a great fit for your {{seats}} reps, with plenty of room to grow.\n\nFancy grabbing Thursday with your ops lead too?\n\nTalk soon,\n{{sender}}',
formal: 'Dear {{first_name}},\n\nThank you for presenting the current pipeline process at {{company}}. Based on our discussion, the {{plan}} plan would accommodate your {{seats}} representatives and future growth.\n\nWould Thursday be convenient to include your operations lead?\n\nKind regards,\n{{sender}}',
},
};
export function EmailComposerDemo() {
return (
<div className="mx-auto w-full max-w-2xl">
<EmailComposer
to={[{ name: 'Maya Chen', email: 'maya@halcyonlabs.io' }]}
templates={DEMO_TEMPLATES}
data={{ first_name: 'Maya', company: 'Halcyon Labs', plan: 'Growth', seats: '25', sender: 'Jordan' }}
rewrite={(body, tone, id) => REWRITES[id]?.[tone] ?? body}
/>
</div>
);
}