# Contact Cards Channels — React Contact Section

> Three contact channel cards, each with its own live detail: a copy-to-clipboard email, a chat thread with a typing indicator and a voice line with moving level bars.

- Category: [Contact & Newsletter](https://pixelpapa.com/components/contact.md)
- Tags: contact, cards, channels, support, hover
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/contact/contact-cards-channels

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/contact-cards-channels.json"
```

The file is written to `components/pixelpapa/contact-cards-channels.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUpRight, Check, Copy, Mail, MessagesSquare, Phone } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type ReactNode } from "react";

const ease = [0.22, 1, 0.36, 1] as const;

function CopyEmail({ email }: { email: string }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);

  function copy() {
    void navigator.clipboard?.writeText(email).catch(() => undefined);
    setCopied(true);
    timer.current = setTimeout(() => setCopied(false), 1800);
  }

  return (
    <button
      type="button"
      onClick={copy}
      className="flex w-full items-center justify-between gap-3 rounded-xl border border-border bg-background px-4 py-3 text-left font-mono text-sm text-foreground transition-colors hover:border-primary/50"
    >
      <span className="truncate">{email}</span>
      <span className="relative flex size-5 shrink-0 items-center justify-center text-muted-foreground">
        <AnimatePresence mode="wait" initial={false}>
          <motion.span
            key={copied ? "y" : "n"}
            initial={{ scale: 0.4, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.4, opacity: 0 }}
            transition={{ duration: 0.15 }}
            className={copied ? "text-emerald-500" : undefined}
          >
            {copied ? <Check className="size-4" strokeWidth={3} /> : <Copy className="size-4" />}
          </motion.span>
        </AnimatePresence>
        <span className="sr-only">{copied ? "Copied" : "Copy email address"}</span>
      </span>
    </button>
  );
}

function ChatPeek() {
  return (
    <div className="space-y-2 text-sm">
      <p className="w-fit max-w-[85%] rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-foreground">
        Hi, I am Priya. What are you stuck on?
      </p>
      <p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-primary-foreground">
        Webhooks retry twice?
      </p>
      <div className="flex w-fit items-center gap-1 rounded-2xl rounded-bl-md bg-muted px-3.5 py-3" aria-label="Priya is typing">
        {[0, 1, 2].map((i) => (
          <span
            key={i}
            className="size-1.5 rounded-full bg-muted-foreground motion-reduce:animate-none"
            style={{ animation: `pp-contact-cards-channels-dot 1.2s ease-in-out ${i * 0.15}s infinite` }}
          />
        ))}
      </div>
    </div>
  );
}

function VoiceLevels() {
  const reduce = useReducedMotion();
  const bars = [0.35, 0.7, 0.5, 0.95, 0.6, 0.8, 0.4, 0.9, 0.55, 0.75, 0.45, 0.85, 0.5, 0.65, 0.3, 0.7, 0.5, 0.9, 0.4, 0.6];
  return (
    <div className="flex h-[104px] items-center gap-[5px] rounded-xl border border-border bg-background px-4">
      {bars.map((h, i) => (
        <motion.span
          key={i}
          className="flex-1 rounded-full bg-primary/70"
          initial={{ height: `${h * 60}%` }}
          animate={reduce ? undefined : { height: [`${h * 60}%`, `${(1 - h) * 55 + 18}%`, `${h * 60}%`] }}
          transition={{ duration: 1.1 + (i % 5) * 0.18, repeat: Infinity, ease: "easeInOut" }}
        />
      ))}
    </div>
  );
}

type Channel = { icon: typeof Mail; title: string; meta: string; body: string; cta: string; visual: ReactNode; live?: boolean };

const channels: Channel[] = [
  {
    icon: Mail,
    title: "Email",
    meta: "Replies in about 4 hours",
    body: "Best for detailed questions, invoices and anything with attachments.",
    cta: "Write to us",
    visual: <CopyEmail email="care@parcelbird.co" />,
  },
  {
    icon: MessagesSquare,
    title: "Live chat",
    meta: "3 people online now",
    body: "Talk to the same support engineers who answer the hard tickets.",
    cta: "Start a chat",
    visual: <ChatPeek />,
    live: true,
  },
  {
    icon: Phone,
    title: "Call",
    meta: "Mon–Fri, 09:00–18:00 GMT",
    body: "Prefer a voice? We pick up in under a minute, with no phone tree.",
    cta: "+44 20 4587 0142",
    visual: <VoiceLevels />,
  },
];

export default function ContactCardsChannels() {
  return (
    <section className="w-full bg-background px-6 py-20">
      <style>{`
        @keyframes pp-contact-cards-channels-dot {
          0%, 60%, 100% { transform: translateY(0); opacity: .4; }
          30% { transform: translateY(-4px); opacity: 1; }
        }
      `}</style>
      <div className="mx-auto max-w-6xl">
        <div className="max-w-xl">
          <h2 className="text-4xl font-semibold tracking-tight text-foreground sm:text-5xl">Reach a human, your way.</h2>
          <p className="mt-4 text-lg text-muted-foreground">
            Parcelbird support is seven people in Leeds. Pick whichever channel suits the problem.
          </p>
        </div>
        <motion.div
          initial="hidden"
          whileInView="shown"
          viewport={{ once: true, amount: 0.2 }}
          transition={{ staggerChildren: 0.1 }}
          className="mt-12 grid gap-5 lg:grid-cols-3"
        >
          {channels.map(({ icon: Icon, title, meta, body, cta, visual, live }) => (
            <motion.article
              key={title}
              variants={{ hidden: { opacity: 0, y: 28 }, shown: { opacity: 1, y: 0, transition: { duration: 0.7, ease } } }}
              whileHover={{ y: -6 }}
              className="group flex flex-col rounded-3xl border border-border bg-card p-6 transition-shadow duration-300 hover:shadow-2xl hover:shadow-primary/10"
            >
              <div className="flex items-center justify-between">
                <span className="flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary transition-colors duration-300 group-hover:bg-primary group-hover:text-primary-foreground">
                  <Icon className="size-5" />
                </span>
                <span className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground">
                  {live && (
                    <span className="relative flex size-1.5">
                      <span className="absolute inset-0 animate-ping rounded-full bg-emerald-500 motion-reduce:animate-none" />
                      <span className="relative size-1.5 rounded-full bg-emerald-500" />
                    </span>
                  )}
                  {meta}
                </span>
              </div>
              <h3 className="mt-6 text-xl font-semibold tracking-tight text-card-foreground">{title}</h3>
              <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{body}</p>
              <div className="mt-6 flex flex-1 flex-col justify-end">{visual}</div>
              <a
                href="#"
                className="mt-6 inline-flex items-center justify-between border-t border-border pt-4 text-sm font-medium text-foreground transition-colors hover:text-primary"
              >
                {cta}
                <ArrowUpRight className="size-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
              </a>
            </motion.article>
          ))}
        </motion.div>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "Contact Cards Channels" component from PixelPapa UI to this project.

What it is: Three contact channel cards, each with its own live detail: a copy-to-clipboard email, a chat thread with a typing indicator and a voice line with moving level bars.
Category: Contact & Newsletter. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/contact-cards-channels.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/contact-cards-channels.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/contact-cards-channels" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More contact & newsletter

- [Contact Booking Slots](https://pixelpapa.com/components/contact/contact-booking-slots.md) (Pro): A meeting scheduler card: pick a day from a strip, then a time slot that highlights with a shared-layout pill, switch 12/24-hour format and confirm into a summary.
- [Contact Map Office](https://pixelpapa.com/components/contact/contact-map-office.md) (Pro): A dotted SVG world map with pulsing office pins linked by animated arcs; the office list and the pins select each other and show live local time.
- [Contact Split Form](https://pixelpapa.com/components/contact/contact-split-form.md) (Pro): Two-column contact section whose form has floating labels, inline validation that slides in on blur, a shake on invalid submit and a drawn-check success state.
- [Newsletter Card Perks](https://pixelpapa.com/components/contact/newsletter-card-perks.md): A signup card with a staggered perks list on one side and a fanned stack of past issues that spreads apart on hover on the other.
- [Newsletter Inline Success](https://pixelpapa.com/components/contact/newsletter-inline-success.md): A single-line signup whose submit button shrinks into a spinner, then a green check, while the field collapses around a confirmation message.
