Web InnoventixFreeCode

Contact Card

Original · free

A contact card with avatar, role and quick action buttons.

byWeb InnoventixReact + Tailwind
cardcontactcards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/card-contact.json
card-contact.tsx
"use client";

import { useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type Person = {
  id: string;
  name: string;
  role: string;
  company: string;
  email: string;
  phone: string;
  img: string;
  online: boolean;
};

const PEOPLE: Person[] = [
  {
    id: "amara",
    name: "Amara Okafor",
    role: "Principal Product Designer",
    company: "Linear",
    email: "amara.okafor@linear.app",
    phone: "+1 (415) 555-0182",
    img: "/img/gallery/g04.webp",
    online: true,
  },
  {
    id: "diego",
    name: "Diego Marchetti",
    role: "Staff Engineer, Platform",
    company: "Vercel",
    email: "diego.marchetti@vercel.com",
    phone: "+1 (628) 555-0147",
    img: "/img/gallery/g11.webp",
    online: false,
  },
  {
    id: "yuki",
    name: "Yuki Tanaka",
    role: "Head of Developer Relations",
    company: "Stripe",
    email: "yuki.tanaka@stripe.com",
    phone: "+81 3-5555-0193",
    img: "/img/gallery/g19.webp",
    online: true,
  },
];

function PhoneIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path
        d="M6.5 3.5h3l1.2 4-2 1.4a12 12 0 0 0 5.4 5.4l1.4-2 4 1.2v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.7 2 2 0 0 1 6.5 3.5Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function MailIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <rect x="3.5" y="5.5" width="17" height="13" rx="2.2" stroke="currentColor" strokeWidth="1.6" />
      <path d="m4.5 7 7.5 5.5L19.5 7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
    </svg>
  );
}

function ChatIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path
        d="M4.5 6.5A2 2 0 0 1 6.5 4.5h11a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H9l-4 3.2V15.5H6.5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CopyIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <rect x="8.5" y="8.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.6" />
      <path d="M5.5 15.5V6.5a2 2 0 0 1 2-2h8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path d="m5 12.5 4.2 4.2L19 7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function StarIcon({ filled }: { filled: boolean }) {
  return (
    <svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} aria-hidden="true" className="h-4 w-4">
      <path
        d="m12 3.8 2.5 5.1 5.6.8-4 4 .9 5.6L12 16.7 6.9 19.3l1-5.6-4-4 5.6-.8L12 3.8Z"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinejoin="round"
      />
    </svg>
  );
}

const iconBtn =
  "inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-600 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-300 hover:text-slate-900 hover:shadow-sm active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white dark:focus-visible:ring-offset-slate-900";

function Avatar({ person, size }: { person: Person; size: "md" | "lg" }) {
  const dim = size === "lg" ? "h-16 w-16" : "h-12 w-12";
  const dot = size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
  return (
    <div className="relative shrink-0">
      <span className="absolute inset-0 -z-10 rounded-full bg-gradient-to-br from-indigo-400/40 to-violet-400/40 blur-[6px] dark:from-indigo-500/30 dark:to-violet-500/30" />
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={person.img}
        alt={person.name}
        loading="lazy"
        draggable={false}
        className={`${dim} rounded-full object-cover ring-2 ring-white shadow-sm dark:ring-slate-800`}
      />
      <span
        className={`absolute -bottom-0.5 -right-0.5 ${dot} rounded-full ring-2 ring-white dark:ring-slate-800 ${
          person.online ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-600"
        }`}
        aria-label={person.online ? "Online" : "Offline"}
        role="img"
      />
    </div>
  );
}

function ContactCard({ person, featured }: { person: Person; featured?: boolean }) {
  const reduce = useReducedMotion();
  const [copied, setCopied] = useState(false);
  const [saved, setSaved] = useState(false);

  const copyEmail = async () => {
    try {
      await navigator.clipboard.writeText(person.email);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1600);
    } catch {
      setCopied(false);
    }
  };

  return (
    <motion.article
      initial={reduce ? false : { opacity: 0, y: 14 }}
      whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-40px" }}
      transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
      className={`group relative w-full overflow-hidden rounded-2xl border bg-white p-5 shadow-sm transition-shadow duration-200 hover:shadow-lg dark:bg-slate-900 ${
        featured
          ? "border-indigo-200 ring-1 ring-indigo-100 dark:border-indigo-500/40 dark:ring-indigo-500/20"
          : "border-slate-200 dark:border-slate-800"
      }`}
    >
      <div
        className="cc-shine pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-400/70 to-transparent"
        aria-hidden="true"
      />
      <div className="flex items-start gap-4">
        <Avatar person={person} size="lg" />
        <div className="min-w-0 flex-1 pt-0.5">
          <div className="flex items-start justify-between gap-2">
            <div className="min-w-0">
              <h3 className="truncate text-base font-semibold text-slate-900 dark:text-white">{person.name}</h3>
              <p className="truncate text-sm text-slate-500 dark:text-slate-400">{person.role}</p>
            </div>
            <button
              type="button"
              aria-pressed={saved}
              aria-label={saved ? `Remove ${person.name} from favorites` : `Save ${person.name} to favorites`}
              onClick={() => setSaved((s) => !s)}
              className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border transition-all duration-150 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                saved
                  ? "border-amber-300 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-400"
                  : "border-slate-200 bg-white text-slate-400 hover:text-amber-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
              }`}
            >
              <StarIcon filled={saved} />
            </button>
          </div>
          <span className="mt-2 inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
            <span
              className={`h-1.5 w-1.5 rounded-full ${person.online ? "bg-emerald-500" : "bg-slate-400"}`}
              aria-hidden="true"
            />
            {person.company}
          </span>
        </div>
      </div>

      <dl className="mt-5 space-y-1 text-sm">
        <div className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
          <dt className="sr-only">Email</dt>
          <MailIcon />
          <dd className="truncate">{person.email}</dd>
        </div>
        <div className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
          <dt className="sr-only">Phone</dt>
          <PhoneIcon />
          <dd className="tabular-nums">{person.phone}</dd>
        </div>
      </dl>

      <div className="mt-5 flex items-center gap-2">
        <a
          href={`tel:${person.phone.replace(/[^\d+]/g, "")}`}
          className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all duration-150 hover:-translate-y-0.5 hover:bg-indigo-500 hover:shadow-md active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
        >
          <PhoneIcon />
          Call
        </a>
        <a
          href={`mailto:${person.email}`}
          aria-label={`Email ${person.name}`}
          className={iconBtn}
        >
          <MailIcon />
        </a>
        <button type="button" aria-label={`Message ${person.name}`} className={iconBtn}>
          <ChatIcon />
        </button>
        <button
          type="button"
          onClick={copyEmail}
          aria-label={copied ? "Email copied to clipboard" : `Copy ${person.name}'s email`}
          className={`${iconBtn} ${copied ? "!border-emerald-300 !text-emerald-600 dark:!border-emerald-500/40 dark:!text-emerald-400" : ""}`}
        >
          <AnimatePresence mode="wait" initial={false}>
            <motion.span
              key={copied ? "check" : "copy"}
              initial={reduce ? false : { scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={reduce ? undefined : { scale: 0.5, opacity: 0 }}
              transition={{ duration: 0.15 }}
              className="inline-flex"
            >
              {copied ? <CheckIcon /> : <CopyIcon />}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>
    </motion.article>
  );
}

function CompactRow({ person }: { person: Person }) {
  const [saved, setSaved] = useState(false);
  return (
    <div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-3 transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700">
      <Avatar person={person} size="md" />
      <div className="min-w-0 flex-1">
        <p className="truncate text-sm font-semibold text-slate-900 dark:text-white">{person.name}</p>
        <p className="truncate text-xs text-slate-500 dark:text-slate-400">
          {person.role} · {person.company}
        </p>
      </div>
      <a href={`mailto:${person.email}`} aria-label={`Email ${person.name}`} className={iconBtn}>
        <MailIcon />
      </a>
      <button
        type="button"
        aria-pressed={saved}
        aria-label={saved ? `Remove ${person.name} from favorites` : `Save ${person.name} to favorites`}
        onClick={() => setSaved((s) => !s)}
        className={`inline-flex h-10 w-10 items-center justify-center rounded-xl border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
          saved
            ? "border-amber-300 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-400"
            : "border-slate-200 bg-white text-slate-400 hover:text-amber-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
        }`}
      >
        <StarIcon filled={saved} />
      </button>
    </div>
  );
}

export default function CardContact() {
  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes cc-shine-kf {
          0%, 100% { opacity: 0.25; }
          50% { opacity: 1; }
        }
        .cc-shine { animation: cc-shine-kf 3.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cc-shine { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mx-auto mb-10 max-w-2xl text-center">
          <span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            Contact cards
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Reach your people in one tap
          </h2>
          <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
            Avatars, live status, and quick actions — call, email, message, or copy in a click.
          </p>
        </header>

        <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
          {PEOPLE.map((p, i) => (
            <ContactCard key={p.id} person={p} featured={i === 0} />
          ))}
        </div>

        <div className="mx-auto mt-8 max-w-md space-y-2">
          <p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
            Compact variant
          </p>
          {PEOPLE.map((p) => (
            <CompactRow key={`row-${p.id}`} person={p} />
          ))}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →