Skip to main content

UI

Contact button

Hover-reveal copy email CTA

Client · Motion

Live preview

Interact with the component below — same behavior as on the About page.

Summary

A primary CTA button that swaps between a compact “Contact” label and the full email address on hover or focus. Click copies the address to the clipboard with a check icon confirmation. Built with Motion layout animations and a clipboard fallback for older browsers.

  • Layout animation between compact and expanded states
  • Copy-to-clipboard with success feedback icon
  • Focus-visible expand matches hover behavior
  • Configurable email via prop

Add to your project

Run the CLI to copy files into your project. Pick your language and styling below — the command updates automatically.

1. Add via CLI (recommended)

npx @shashank-portfolio/cli add contact-button

2. Install dependencies

npm install motion lucide-react

Required packages

  • motion ^12.0.0
  • lucide-react ^0.562.0 · Mail, Copy, Check icons

3. Manual installation

Copy the file below. Pass email as a prop, or change the default import from siteConfig to your own constant.

contact-button.tsx

"use client";

import { AnimatePresence, motion } from "motion/react";
import { Check, Copy, Mail } from "lucide-react";
import { useState } from "react";
import { siteConfig } from "@/lib/metadata";
import type { ReactNode } from "react";

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

type ContactButtonProps = {
  email?: string;
};

export function ContactButton({
  email = siteConfig.email,
}: ContactButtonProps): ReactNode {
  const [open, setOpen] = useState(false);
  const [copied, setCopied] = useState(false);

  const handleCopy = async (): Promise<void> => {
    try {
      await navigator.clipboard.writeText(email);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1600);
    } catch {
      const ta = document.createElement("textarea");
      ta.value = email;
      ta.style.position = "fixed";
      ta.style.opacity = "0";
      document.body.appendChild(ta);
      ta.select();
      try {
        document.execCommand("copy");
        setCopied(true);
        window.setTimeout(() => setCopied(false), 1600);
      } catch {}
      document.body.removeChild(ta);
    }
  };

  return (
    <motion.button
      type="button"
      layout
      onClick={handleCopy}
      onHoverStart={() => setOpen(true)}
      onHoverEnd={() => setOpen(false)}
      onFocus={() => setOpen(true)}
      onBlur={() => setOpen(false)}
      aria-label={
        copied ? "Email copied" : open ? `Copy ${email}` : "Show email"
      }
      transition={{ layout: { duration: 0.55, ease: EASE } }}
      style={{ borderRadius: 12 }}
      className="focus-ring relative inline-flex h-11 cursor-pointer items-center justify-center bg-foreground px-5 text-sm font-medium text-background"
    >
      <motion.span
        layout="position"
        className="relative inline-flex items-center"
      >
        <AnimatePresence initial={false} mode="popLayout">
          {open ? (
            <motion.span
              key="email"
              layout="position"
              initial={{ opacity: 0, filter: "blur(8px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, filter: "blur(8px)" }}
              transition={{ duration: 0.35, ease: EASE }}
              className="inline-flex items-center gap-2 whitespace-nowrap"
            >
              <span className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
                <AnimatePresence initial={false} mode="wait">
                  {copied ? (
                    <motion.span
                      key="check"
                      initial={{ scale: 0.5, opacity: 0 }}
                      animate={{ scale: 1, opacity: 1 }}
                      exit={{ scale: 0.5, opacity: 0 }}
                      transition={{ duration: 0.2, ease: EASE }}
                      className="inline-flex"
                    >
                      <Check className="h-4 w-4" aria-hidden="true" />
                    </motion.span>
                  ) : (
                    <motion.span
                      key="copy"
                      initial={{ scale: 0.5, opacity: 0 }}
                      animate={{ scale: 1, opacity: 1 }}
                      exit={{ scale: 0.5, opacity: 0 }}
                      transition={{ duration: 0.2, ease: EASE }}
                      className="inline-flex"
                    >
                      <Copy className="h-4 w-4" aria-hidden="true" />
                    </motion.span>
                  )}
                </AnimatePresence>
              </span>
              <span className="tabular-nums">{email}</span>
            </motion.span>
          ) : (
            <motion.span
              key="contact"
              layout="position"
              initial={{ opacity: 0, filter: "blur(8px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, filter: "blur(8px)" }}
              transition={{ duration: 0.35, ease: EASE }}
              className="inline-flex items-center gap-2 whitespace-nowrap"
            >
              <Mail className="h-4 w-4 shrink-0" aria-hidden="true" />
              <span>Contact</span>
            </motion.span>
          )}
        </AnimatePresence>
      </motion.span>
    </motion.button>
  );
}

4. Use in your app

"use client";

import { ContactButton } from "@/components/contact/contact-button";

export function HeroCtas() {
  return <ContactButton email="you@example.com" />;
}

Let’s connect

I’m always open to discussing new projects, creative ideas, or opportunities to be part of your visions. Just reach out!

2026 © Built with Next.js

By Love ❤️