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)" },
},
},
//...
},
//...