Click to Copy

September 2024

A dynamic click-to-copy button with animated labels. On desktop, hovering reveals a "Click to copy" message, while clicking shows a confirmation. On mobile, tapping directly copies and confirms.

Example

Hover the button
Click to copy
✨ Copied ✨

Code

"use client";

import { useEffect, useRef, useState } from "react";
import { useClickOutside } from "@/lib/use-click-outside";
import { useMediaQuery } from "@/lib/use-media-query";
import { cn } from "@/lib/utils";

interface ClickToCopyProps {
  copyText: string;
  label: string;
  confirmation: string;
}

export function ClickToCopy({
  copyText,
  label,
  confirmation,
}: ClickToCopyProps) {
  const [isHovered, setIsHovered] = useState(false);
  const [hasClicked, setHasClicked] = useState(false);
  const [mobileClicked, setMobileClicked] = useState(false);

  const isMobile = useMediaQuery("(pointer: coarse)");

  const buttonRef = useRef<HTMLButtonElement>(null);

  useClickOutside(buttonRef, () => {
    if (mobileClicked) setMobileClicked(false);
  });

  useEffect(() => {
    async function copyEmail() {
      try {
        await navigator.clipboard.writeText(copyText);
      } catch (err) {
        console.error("Failed to copy email:", err);
        // Fallback method
        const textarea = document.createElement("textarea");
        textarea.value = copyText;
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();
        try {
          document.execCommand("copy");
        } catch (err) {
          console.error("Fallback copy method failed:", err);
        }
        document.body.removeChild(textarea);
      }
    }

    if (hasClicked || mobileClicked) {
      copyEmail();
    }
  }, [hasClicked, isMobile, mobileClicked, copyText]);

  return (
    <div className="flex flex-col items-center gap-3 font-code [--ease-custom:cubic-bezier(0.215,0.61,0.355,1)]">
      <div className="relative w-full overflow-hidden whitespace-nowrap text-center text-sm [&>div]:transition-transform [&>div]:duration-500 [&>div]:ease-[--ease-custom]">
        <div
          className={cn("h-full w-full", {
            "desktop:translate-y-[-100%]": isHovered,
            "desktop:translate-y-[-200%]": isHovered && hasClicked,
            "mobile:translate-y-[-100%]": mobileClicked,
          })}
        >
          {label}
        </div>
        <div
          className={cn(
            "absolute left-0 top-0 h-full w-full translate-y-[100%] mobile:hidden",
            {
              "desktop:translate-y-[0%]": isHovered,
              "desktop:translate-y-[-100%]": isHovered && hasClicked,
            },
          )}
        >
          Click to copy
        </div>
        <div
          className={cn(
            "absolute left-0 top-0 h-full w-full mobile:translate-y-[100%] desktop:translate-y-[200%]",
            {
              "desktop:translate-y-[100%]": isHovered,
              "desktop:translate-y-[0%]": isHovered && hasClicked,
              "mobile:translate-y-[0%]": mobileClicked,
            },
          )}
        >
          {confirmation}
        </div>
      </div>
      <button
        ref={buttonRef}
        onPointerEnter={() => !isMobile && setIsHovered(true)}
        onPointerLeave={() => {
          if (!isMobile) {
            setIsHovered(false);
            setHasClicked(false);
          }
        }}
        onPointerDown={() => !isMobile && setHasClicked(true)}
        onClick={() => isMobile && setMobileClicked(true)}
        aria-label={label}
        className="group cursor-copy rounded-2xl bg-neutral-100 px-8 py-5 text-xl transition-colors duration-200 ease-[--ease-custom] hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700"
      >
        <div className="transition-transform duration-500 ease-[--ease-custom] desktop:group-active:scale-[97%]">
          <div className="transition-transform duration-500 ease-[--ease-custom] group-hover:scale-[97%]">
            {copyText}
          </div>
        </div>
      </button>
    </div>
  );
}

Tailwind mobile and desktop media queries

//...
theme: {
  extend: {
    screens: {
      mobile: { raw: "(pointer: coarse)" },
      desktop: { raw: "(pointer: fine), (pointer: none)" },
    },
  },
  //...
},
//...