Contact Card
A person card with quick actions: copy email or phone with feedback, see their local time, and expand for more details.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/contact-card.json'use client'
import { Fragment, useEffect, useState } from 'react'
import './contact-card.css'
export interface Person { name: string; role: string; email: string; phone: string; tz: string; online?: boolean; details: [string, string][] }
export interface ContactCardProps {
person: Person
layout?: 'center' | 'row'
showTime?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Person card with copy actions and local time. */
export function ContactCard({ person: p, layout = 'center', showTime = true, fill = 'dark', className }: ContactCardProps) {
const [ok, setOk] = useState('')
const [open, setOpen] = useState(false)
const [fav, setFav] = useState(false)
const [now, setNow] = useState<Date | null>(null)
useEffect(() => { setNow(new Date()); const i = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(i) }, [])
const copy = (k: string, v: string) => { navigator.clipboard?.writeText(v).catch(() => {}); setOk(k); setTimeout(() => setOk(''), 1300) }
const local = now ? now.toLocaleTimeString('en-US', { timeZone: p.tz, hour: 'numeric', minute: '2-digit' }) : ''
const off = now ? Math.round((new Date(now.toLocaleString('en-US', { timeZone: p.tz })).getTime() - new Date(now.toLocaleString('en-US')).getTime()) / 3600000) : 0
const first = p.name.split(' ')[0]
const ini = p.name.split(' ').map((x) => x[0]).join('').slice(0, 2)
return (
<div className={['sd-cnt', className].filter(Boolean).join(' ')} data-fill={fill} data-layout={layout}>
<div className="sd-cnt__top">
<span className="sd-cnt__av" aria-hidden>{ini}{p.online && <i />}</span>
<div><p className="sd-cnt__n">{p.name}</p><p className="sd-cnt__r">{p.role}{p.online ? ' · Online' : ''}</p></div>
<button type="button" className="sd-cnt__fav" aria-pressed={fav} aria-label={fav ? `Unfavourite ${first}` : `Favourite ${first}`} onClick={() => setFav(!fav)}>
<svg viewBox="0 0 20 20" aria-hidden><path d="m10 2.5 2.3 4.8 5.2.6-3.8 3.6 1 5.2L10 14.2l-4.7 2.5 1-5.2-3.8-3.6 5.2-.6z" /></svg>
</button>
</div>
{showTime && now && <span className="sd-cnt__tm">{local} for {first} · {off === 0 ? 'same time' : `${Math.abs(off)}h ${off > 0 ? 'ahead' : 'behind'}`}</span>}
<div className="sd-cnt__acts">
<button type="button" className="sd-cnt__a" data-ok={ok === 'email' || undefined} aria-label={`Copy email ${p.email}`} onClick={() => copy('email', p.email)}>
<svg viewBox="0 0 20 20" aria-hidden>{ok === 'email' ? <path d="m5 10.5 3 3 7-7" /> : <><rect x="2.5" y="4.5" width="15" height="11" rx="2" /><path d="m3 5.5 7 5 7-5" /></>}</svg>{ok === 'email' ? 'Copied' : 'Email'}
</button>
<button type="button" className="sd-cnt__a" data-ok={ok === 'phone' || undefined} aria-label={`Copy phone ${p.phone}`} onClick={() => copy('phone', p.phone)}>
<svg viewBox="0 0 20 20" aria-hidden>{ok === 'phone' ? <path d="m5 10.5 3 3 7-7" /> : <path d="M6 3h2l1.5 4-2 1.2a9 9 0 0 0 4.3 4.3L13 10.5l4 1.5v2a2 2 0 0 1-2 2A12 12 0 0 1 4 5a2 2 0 0 1 2-2z" />}</svg>{ok === 'phone' ? 'Copied' : 'Phone'}
</button>
<a className="sd-cnt__a" href={`mailto:${p.email}`}><svg viewBox="0 0 20 20" aria-hidden><path d="M3 10h11M10 5l5 5-5 5" /></svg>Message</a>
</div>
<span className="sr" role="status">{ok ? `${ok === 'email' ? 'Email' : 'Phone'} copied` : ''}</span>
<button type="button" className="sd-cnt__more" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? 'Fewer details' : 'More details'}<svg viewBox="0 0 12 12" aria-hidden><path d="m3 4.5 3 3 3-3" /></svg></button>
{open && <dl className="sd-cnt__dl">{p.details.map(([k, v]) => <Fragment key={k}><dt>{k}</dt><dd>{v}</dd></Fragment>)}</dl>}
</div>
)
}.sd-cnt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-cnt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-cnt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cnt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cnt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cnt { width: min(100%, 310px); padding: 18px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); text-align: center; }
.sd-cnt[data-layout='row'] { text-align: left; }
.sd-cnt__top { position: relative; }
.sd-cnt[data-layout='row'] .sd-cnt__top { display: flex; align-items: center; gap: 14px; }
.sd-cnt__av { display: grid; place-items: center; width: 68px; height: 68px; margin: 0 auto 10px; border-radius: 99px; background: var(--soft); font-size: 22px; font-weight: 600; letter-spacing: -0.02em; position: relative; }
.sd-cnt[data-layout='row'] .sd-cnt__av { margin: 0; width: 56px; height: 56px; flex: none; font-size: 18px; }
.sd-cnt__av i { position: absolute; right: 2px; bottom: 2px; width: 14px; height: 14px; border-radius: 99px; background: var(--fg); box-shadow: 0 0 0 3px var(--bg); }
.sd-cnt__n { margin: 0; font-size: 17px; font-weight: 600; }
.sd-cnt__r { margin: 2px 0 0; font-size: 13px; color: var(--mute); }
.sd-cnt__fav { position: absolute; top: -6px; right: -6px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 99px; }
.sd-cnt__fav svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; transition: transform 200ms cubic-bezier(0.3, 1.8, 0.4, 1); }
.sd-cnt__fav[aria-pressed='true'] svg { fill: currentColor; transform: scale(1.1); }
.sd-cnt__tm { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; padding: 5px 10px; border-radius: 99px; background: var(--soft); font-size: 12px; font-variant-numeric: tabular-nums; }
.sd-cnt__acts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 16px; }
.sd-cnt__a { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 14px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; text-decoration: none; color: inherit; transition: background-color 120ms; }
.sd-cnt__a:hover { background: var(--soft); }
.sd-cnt__a svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.sd-cnt__a[data-ok] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-cnt__more { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; margin-top: 14px; padding: 8px; border-radius: 10px; font-size: 13px; color: var(--mute) !important; }
.sd-cnt__more svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; transition: transform 200ms; }
.sd-cnt__more[aria-expanded='true'] svg { transform: rotate(180deg); }
.sd-cnt__dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 12px; margin: 4px 0 0; padding-top: 10px; border-top: 1px solid var(--line); font-size: 13px; text-align: left; animation: sd-cnt-in 220ms ease-out; }
.sd-cnt__dl dt { color: var(--mute); }
.sd-cnt__dl dd { margin: 0; text-align: right; }
@keyframes sd-cnt-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-cnt *, .sd-cnt *::before, .sd-cnt *::after { animation: none !important; transition: none !important; } }Overview
Avatar, name, role and a live local-time line (“3:42 pm for Ana, 6 hours ahead”). Quick actions copy the email or phone and confirm with a check.
More details expands team, manager and links. A “Favourite” star pins them.
When to use
- Team directories, CRM records, account owner cards.
When not to use
- Public profiles with social content: use a profile page.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/contact-card.json$
npx shadcn@latest add https://sundaeui.dev/r/contact-card.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/contact-card.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/contact-card.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy contact-card.tsx and contact-card.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ContactCard } from '@/components/sundae/contact-card'
<ContactCard />Props
| Prop | Type | Default |
|---|---|---|
person*Name, role, email, phone, tz, details. | Person | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Actions are buttons naming what they copy; results are announced.
- Details use a disclosure button with aria-expanded.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cnt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Contact Card free?
- Yes. Contact Card is free for personal and commercial projects, with full source on this page.
- How do I install Contact Card?
- Run npx shadcn@latest add https://sundaeui.dev/r/contact-card.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Contact Card support keyboard and screen readers?
- Actions are buttons naming what they copy; results are announced. Details use a disclosure button with aria-expanded.
- Does Contact Card respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Hover Card
Components · RefinedAPI Key Row
Components · RefinedShare Sheet
Components · RefinedCode Tabs
Components · RefinedLink Preview
Components · RefinedToast Stack
Components · Refined