Person Profile New
A contact record header with quick email and call actions, plus a field list you edit inline: email, phone, title, city, tags and owner.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/crm-person-profile.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/crm/PersonProfile.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Briefcase, Building2, Calendar, Check, ChevronDown, Copy, Linkedin, Mail, MapPin, MoreHorizontal, Phone, PhoneOff, Plus, Star, Tag, UserRound, X } from 'lucide-react';
import { cn } from '@/lib/cn';
export type Owner = { name: string; initials: string; color: string };
export type Person = {
name: string;
initials: string;
title: string;
company: string;
email: string;
phone: string;
city: string;
linkedin: string;
tags: string[];
owner: Owner;
created: string;
};
type TextKey = 'email' | 'phone' | 'city' | 'linkedin' | 'title';
export type PersonProfileProps = {
person: Person;
owners?: Owner[];
onChange?: (person: Person) => void;
/** Types a value into a field and saves it, as if a teammate edited it live. */
autoType?: { field: TextKey; text: string } | null;
className?: string;
};
const FIELDS: { key: TextKey; label: string; icon: typeof Mail }[] = [
{ key: 'email', label: 'Email', icon: Mail },
{ key: 'phone', label: 'Phone', icon: Phone },
{ key: 'title', label: 'Job title', icon: Briefcase },
{ key: 'city', label: 'City', icon: MapPin },
{ key: 'linkedin', label: 'LinkedIn', icon: Linkedin },
];
function Avatar({ owner, size = 20 }: { owner: Owner; size?: number }) {
return (
<span className="grid shrink-0 place-items-center rounded-full font-semibold text-white" style={{ width: size, height: size, background: owner.color, fontSize: size * 0.4 }}>
{owner.initials}
</span>
);
}
function Row({ icon: Icon, label, children }: { icon: typeof Mail; label: string; children: ReactNode }) {
return (
<div className="grid min-h-[36px] grid-cols-[104px_minmax(0,1fr)] items-center gap-2 sm:grid-cols-[124px_minmax(0,1fr)]">
<span className="flex items-center gap-2 text-xs text-ink-500">
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</span>
<div className="min-w-0">{children}</div>
</div>
);
}
function EditableText({ value, field, autoType, onSave }: { value: string; field: TextKey; autoType?: string; onSave: (v: string) => void }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saved, setSaved] = useState(false);
const [auto, setAuto] = useState(false);
const input = useRef<HTMLInputElement>(null);
const save = useRef(onSave);
save.current = onSave;
useEffect(() => {
if (!autoType) return;
setAuto(true);
setEditing(true);
setDraft('');
let i = 0;
let done = 0;
const id = window.setInterval(() => {
i += 1;
setDraft(autoType.slice(0, i));
if (i >= autoType.length) {
clearInterval(id);
done = window.setTimeout(() => {
setEditing(false);
setAuto(false);
save.current(autoType);
setSaved(true);
}, 350);
}
}, 45);
return () => {
clearInterval(id);
clearTimeout(done);
};
}, [autoType]);
useEffect(() => {
if (editing && !auto) input.current?.focus({ preventScroll: true });
}, [editing, auto]);
useEffect(() => {
if (!saved) return;
const id = window.setTimeout(() => setSaved(false), 1400);
return () => clearTimeout(id);
}, [saved]);
const commit = () => {
setEditing(false);
if (draft.trim() && draft !== value) {
onSave(draft.trim());
setSaved(true);
} else setDraft(value);
};
if (editing)
return (
<input
ref={input}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => !auto && commit()}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setDraft(value);
setEditing(false);
}
}}
aria-label={field}
className="h-8 w-full rounded-lg bg-ink-950 px-2 text-sm text-ink-50 outline-none ring-1 ring-brand-400/60 transition-shadow focus:ring-2"
/>
);
const link = field === 'email' || field === 'linkedin';
return (
<button
onClick={() => {
setDraft(value);
setEditing(true);
}}
className="focus-ring group flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-sm transition-colors hover:bg-white/[0.04]"
>
<span className={cn('truncate', link ? 'text-brand-300' : 'text-ink-100')}>{value}</span>
{saved ? (
<span className="ml-auto flex shrink-0 items-center gap-1 text-[11px] text-emerald-400 animate-fade-in">
<Check className="h-3 w-3" /> Saved
</span>
) : (
<span className="ml-auto shrink-0 text-[11px] text-ink-500 opacity-0 transition-opacity group-hover:opacity-100">Edit</span>
)}
</button>
);
}
export function PersonProfile({ person, owners = [], onChange, autoType, className }: PersonProfileProps) {
const [fav, setFav] = useState(true);
const [copied, setCopied] = useState(false);
const [call, setCall] = useState<number | null>(null);
const [menu, setMenu] = useState(false);
const [ownerOpen, setOwnerOpen] = useState(false);
const [tagDraft, setTagDraft] = useState<string | null>(null);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
if (call === null) return;
const t0 = performance.now() - call * 1000;
const id = window.setInterval(() => setCall(Math.floor((performance.now() - t0) / 1000)), 500);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [call === null]);
useEffect(() => {
if (!copied) return;
const id = window.setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(id);
}, [copied]);
useEffect(() => {
if (!menu && !ownerOpen) return;
const close = (e: PointerEvent) => {
if (root.current && !root.current.contains(e.target as Node)) {
setMenu(false);
setOwnerOpen(false);
}
};
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, [menu, ownerOpen]);
const set = (patch: Partial<Person>) => onChange?.({ ...person, ...patch });
const copy = async () => {
try {
await navigator.clipboard.writeText(person.email);
} catch {
/* clipboard can be unavailable in iframes */
}
setCopied(true);
};
const addTag = () => {
const t = tagDraft?.trim();
if (t && !person.tags.includes(t)) set({ tags: [...person.tags, t] });
setTagDraft(null);
};
return (
<div ref={root} className={cn('w-full max-w-xl overflow-hidden rounded-2xl bg-ink-900 shadow-card', className)}>
<div className="relative h-20 overflow-hidden border-b border-white/5" style={{ background: 'radial-gradient(120% 140% at 0% 0%, rgba(56,189,248,0.22), transparent 60%), radial-gradient(90% 120% at 100% 0%, rgba(129,140,248,0.16), transparent 60%)' }}>
<div className="absolute inset-0 dot-bg opacity-60" />
</div>
<div className="px-4 pb-4 sm:px-5">
<div className="-mt-9 flex items-end justify-between gap-3">
<span className="relative grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-gradient-to-br from-brand-300 to-brand-700 text-2xl font-semibold text-white ring-4 ring-ink-900">
{person.initials}
<span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-emerald-400 ring-[3px] ring-ink-900" title="Replied this week" />
</span>
<div className="relative flex flex-wrap justify-end gap-1.5">
<button onClick={copy} className="focus-ring flex h-8 items-center gap-1.5 rounded-lg bg-white/[0.06] px-2.5 text-xs font-medium text-ink-100 transition-colors hover:bg-white/10" aria-label="Copy email address">
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Mail className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{copied ? 'Copied' : 'Email'}</span>
</button>
<button
onClick={() => setCall((c) => (c === null ? 0 : null))}
className={cn('focus-ring flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-colors', call === null ? 'bg-white/[0.06] text-ink-100 hover:bg-white/10' : 'bg-rose-500/15 text-rose-300 hover:bg-rose-500/25')}
aria-label={call === null ? 'Call' : 'End call'}
>
{call === null ? <Phone className="h-3.5 w-3.5" /> : <PhoneOff className="h-3.5 w-3.5" />}
{call === null ? <span className="hidden sm:inline">Call</span> : <span className="font-mono tabular-nums">{`${Math.floor(call / 60)}:${String(call % 60).padStart(2, '0')}`}</span>}
</button>
<button onClick={() => setMenu((m) => !m)} aria-label="More actions" aria-expanded={menu} className="focus-ring grid h-8 w-8 place-items-center rounded-lg bg-white/[0.06] text-ink-300 transition-colors hover:bg-white/10 hover:text-ink-100">
<MoreHorizontal className="h-4 w-4" />
</button>
{menu && (
<div role="menu" className="absolute right-0 top-10 z-20 w-44 rounded-xl bg-ink-850 p-1 shadow-pop animate-scale-in">
{['Merge duplicate', 'Export as vCard', 'Add to sequence', 'Delete'].map((m) => (
<button key={m} role="menuitem" onClick={() => setMenu(false)} className={cn('focus-ring flex w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-white/5', m === 'Delete' ? 'text-rose-300' : 'text-ink-200')}>
{m}
</button>
))}
</div>
)}
</div>
</div>
<div className="mt-3 flex items-start gap-2">
<div className="min-w-0 flex-1">
<h3 className="truncate text-lg font-semibold tracking-tight text-ink-50">{person.name}</h3>
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-sm text-ink-400">
<span className="truncate">{person.title}</span>
<span className="text-ink-600">@</span>
<span className="inline-flex items-center gap-1 rounded-md bg-white/[0.06] px-1.5 py-0.5 text-xs text-ink-200">
<Building2 className="h-3 w-3 text-brand-300" /> {person.company}
</span>
</p>
</div>
<button onClick={() => setFav((f) => !f)} aria-label={fav ? 'Remove from favorites' : 'Add to favorites'} aria-pressed={fav} className="focus-ring grid h-8 w-8 place-items-center rounded-lg text-ink-400 transition-colors hover:bg-white/5">
<Star className={cn('h-4 w-4 transition-all duration-300 ease-spring', fav ? 'scale-110 fill-amber-300 text-amber-300' : 'scale-100')} />
</button>
</div>
<div className="mt-4 space-y-0.5 border-t border-white/5 pt-3">
{FIELDS.map((f) => (
<Row key={f.key} icon={f.icon} label={f.label}>
<EditableText field={f.key} value={person[f.key]} autoType={autoType?.field === f.key ? autoType.text : undefined} onSave={(v) => set({ [f.key]: v } as Partial<Person>)} />
</Row>
))}
<Row icon={Tag} label="Tags">
<div className="flex flex-wrap items-center gap-1 px-2 py-1">
{person.tags.map((t) => (
<span key={t} className="group inline-flex items-center gap-1 rounded-md bg-brand-500/10 py-0.5 pl-1.5 pr-1 text-xs text-brand-200 animate-scale-in">
{t}
<button onClick={() => set({ tags: person.tags.filter((x) => x !== t) })} aria-label={`Remove tag ${t}`} className="rounded text-brand-300/60 transition-colors hover:text-brand-100">
<X className="h-3 w-3" />
</button>
</span>
))}
{tagDraft === null ? (
<button onClick={() => setTagDraft('')} className="focus-ring inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-ink-500 transition-colors hover:bg-white/5 hover:text-ink-200">
<Plus className="h-3 w-3" /> Add
</button>
) : (
<input
autoFocus
value={tagDraft}
onChange={(e) => setTagDraft(e.target.value)}
onBlur={addTag}
onKeyDown={(e) => {
if (e.key === 'Enter') addTag();
if (e.key === 'Escape') setTagDraft(null);
}}
placeholder="New tag"
aria-label="New tag"
className="h-6 w-24 rounded-md bg-ink-950 px-1.5 text-xs text-ink-50 outline-none ring-1 ring-brand-400/60"
/>
)}
</div>
</Row>
<Row icon={UserRound} label="Owner">
<div className="relative">
<button onClick={() => setOwnerOpen((o) => !o)} aria-haspopup="listbox" aria-expanded={ownerOpen} className="focus-ring flex h-8 items-center gap-2 rounded-lg px-2 text-sm text-ink-100 transition-colors hover:bg-white/[0.04]">
<Avatar owner={person.owner} />
<span className="truncate">{person.owner.name}</span>
<ChevronDown className="h-3.5 w-3.5 text-ink-500" />
</button>
{ownerOpen && (
<div role="listbox" aria-label="Owner" className="absolute left-0 top-9 z-20 w-48 rounded-xl bg-ink-850 p-1 shadow-pop animate-scale-in">
{owners.map((o) => (
<button
key={o.name}
role="option"
aria-selected={o.name === person.owner.name}
onClick={() => {
set({ owner: o });
setOwnerOpen(false);
}}
className="focus-ring flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-ink-200 transition-colors hover:bg-white/5"
>
<Avatar owner={o} size={18} />
<span className="flex-1 truncate">{o.name}</span>
{o.name === person.owner.name && <Check className="h-3.5 w-3.5 text-brand-300" />}
</button>
))}
</div>
)}
</div>
</Row>
<Row icon={Calendar} label="Created">
<span className="block truncate px-2 text-sm text-ink-400">{person.created}</span>
</Row>
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/5 pt-3 text-[11px] text-ink-500">
<span className="truncate">Last reply 2h ago · synced from email</span>
<button onClick={copy} className="focus-ring flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 transition-colors hover:bg-white/5 hover:text-ink-200">
<Copy className="h-3 w-3" /> Copy email
</button>
</div>
</div>
</div>
);
}
const OWNERS: Owner[] = [
{ name: 'Lena Brooks', initials: 'LB', color: '#0ea5e9' },
{ name: 'Marcus Webb', initials: 'MW', color: '#8b5cf6' },
{ name: 'Priya Raman', initials: 'PR', color: '#10b981' },
];
const SAMPLE: Person = {
name: 'Sofia Andersen',
initials: 'SA',
title: 'VP of Operations',
company: 'Halcyon Freight',
email: 'sofia@halcyonfreight.io',
phone: '+1 (415) 555-0142',
city: 'Oakland, CA',
linkedin: 'in/sofia-andersen',
tags: ['Decision maker', 'Q4 pilot'],
owner: OWNERS[0],
created: 'Mar 14, 2026 · via web form',
};
export function PersonProfileDemo() {
const [person, setPerson] = useState(SAMPLE);
const [autoType, setAutoType] = useState<PersonProfileProps['autoType']>(null);
// Auto-play once: type a new city and save it, as if a teammate edited the record live.
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setTimeout(() => setAutoType({ field: 'city', text: 'Lisbon, Portugal' }), 800);
return () => clearTimeout(id);
}, []);
return <PersonProfile person={person} owners={OWNERS} onChange={setPerson} autoType={autoType} />;
}