Copy Button
Copies text to the clipboard and trades its icon for a check for a moment, with a label or as a single icon.
https://easeui.dev/invite/7f3k9q
"use client";
import { CopyButton } from "@/components/motion/copy-button";
const INVITE = "https://easeui.dev/invite/7f3k9q";
export function CopyButtonPreview() {
return (
<div className="flex w-full max-w-sm flex-col items-center gap-5">
<div className="flex w-full items-center gap-2 rounded-full bg-background py-1 pl-4 pr-1 shadow-[0_0_0_1px_var(--border)]">
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{INVITE}</span>
<CopyButton value={INVITE} />
</div>
<CopyButton value={INVITE} label="Copy invite link" copiedLabel="Link copied" />
</div>
);
}
"use client";
// easeui.dev/components/motion/copy-button
import { Check, Copy } from "lucide-react";
import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
type CopyState = "idle" | "copied" | "failed";
export interface CopyButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "value" | "onCopy"> {
/** Text written to the clipboard. */
value: string;
/** Visible text. Leave it out for an icon only button. */
label?: string;
/** Visible text after copying. Default "Copied". */
copiedLabel?: string;
/** How long the copied state stays, in ms. Default 1500. */
timeout?: number;
/** Called after the text reaches the clipboard. */
onCopied?: (value: string) => void;
}
// Both icons, and both labels, share a grid cell and trade places with a fade and a small
// scale. Sharing the cell keeps the button the same width in either state.
const SWAP =
"col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none";
const SHOWN = "scale-100 opacity-100";
const ICON_HIDDEN = "scale-50 opacity-0";
const TEXT_HIDDEN = "scale-[0.97] opacity-0";
export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton(
{ value, label, copiedLabel = "Copied", timeout = 1500, onCopied, className, onClick, ...props },
ref,
) {
const [state, setState] = useState<CopyState>("idle");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setState("copied");
onCopied?.(value);
} catch {
// Clipboard access can be blocked, for example in an insecure context.
setState("failed");
}
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setState("idle"), timeout);
};
const copied = state === "copied";
const status = copied ? "Copied to clipboard" : state === "failed" ? "Could not copy" : "";
return (
<button
ref={ref}
type="button"
aria-label={label ?? "Copy"}
data-state={state}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) void copy();
}}
className={cn(
"relative inline-flex h-9 shrink-0 touch-manipulation select-none items-center justify-center gap-2 rounded-full bg-card text-sm font-medium text-foreground outline-none",
"shadow-[0_0_0_1px_var(--border)] transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100",
"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
label ? "px-3.5" : "w-9 after:absolute after:-inset-1",
className,
)}
{...props}
>
<span aria-hidden="true" className="grid place-items-center">
<Copy className={cn(SWAP, "h-4 w-4", copied ? ICON_HIDDEN : SHOWN)} />
<Check className={cn(SWAP, "h-4 w-4", copied ? SHOWN : ICON_HIDDEN)} />
</span>
{label ? (
<span aria-hidden="true" className="grid whitespace-nowrap">
<span className={cn(SWAP, copied ? TEXT_HIDDEN : SHOWN)}>{label}</span>
<span className={cn(SWAP, copied ? SHOWN : TEXT_HIDDEN)}>{copiedLabel}</span>
</span>
) : null}
<span aria-live="polite" className="sr-only">
{status}
</span>
</button>
);
});
Installation
$ bunx --bun shadcn add @easeui/copy-button
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react tailwind-merge - 3
Add the source files
components/motion/copy-button.tsx "use client"; // easeui.dev/components/motion/copy-button import { Check, Copy } from "lucide-react"; import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; type CopyState = "idle" | "copied" | "failed"; export interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "value" | "onCopy"> { /** Text written to the clipboard. */ value: string; /** Visible text. Leave it out for an icon only button. */ label?: string; /** Visible text after copying. Default "Copied". */ copiedLabel?: string; /** How long the copied state stays, in ms. Default 1500. */ timeout?: number; /** Called after the text reaches the clipboard. */ onCopied?: (value: string) => void; } // Both icons, and both labels, share a grid cell and trade places with a fade and a small // scale. Sharing the cell keeps the button the same width in either state. const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none"; const SHOWN = "scale-100 opacity-100"; const ICON_HIDDEN = "scale-50 opacity-0"; const TEXT_HIDDEN = "scale-[0.97] opacity-0"; export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton( { value, label, copiedLabel = "Copied", timeout = 1500, onCopied, className, onClick, ...props }, ref, ) { const [state, setState] = useState<CopyState>("idle"); const timer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const copy = async () => { try { await navigator.clipboard.writeText(value); setState("copied"); onCopied?.(value); } catch { // Clipboard access can be blocked, for example in an insecure context. setState("failed"); } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setState("idle"), timeout); }; const copied = state === "copied"; const status = copied ? "Copied to clipboard" : state === "failed" ? "Could not copy" : ""; return ( <button ref={ref} type="button" aria-label={label ?? "Copy"} data-state={state} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) void copy(); }} className={cn( "relative inline-flex h-9 shrink-0 touch-manipulation select-none items-center justify-center gap-2 rounded-full bg-card text-sm font-medium text-foreground outline-none", "shadow-[0_0_0_1px_var(--border)] transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", label ? "px-3.5" : "w-9 after:absolute after:-inset-1", className, )} {...props} > <span aria-hidden="true" className="grid place-items-center"> <Copy className={cn(SWAP, "h-4 w-4", copied ? ICON_HIDDEN : SHOWN)} /> <Check className={cn(SWAP, "h-4 w-4", copied ? SHOWN : ICON_HIDDEN)} /> </span> {label ? ( <span aria-hidden="true" className="grid whitespace-nowrap"> <span className={cn(SWAP, copied ? TEXT_HIDDEN : SHOWN)}>{label}</span> <span className={cn(SWAP, copied ? SHOWN : TEXT_HIDDEN)}>{copiedLabel}</span> </span> ) : null} <span aria-live="polite" className="sr-only"> {status} </span> </button> ); });lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | - | Text written to the clipboard. |
| label? | string | - | Visible text. Leave it out for an icon only button. |
| copiedLabel? | string | Copied | Visible text after copying. Default "Copied". |
| timeout? | number | 1500 | How long the copied state stays, in ms. Default 1500. |
| onCopied? | ((value: string) => void) | - | Called after the text reaches the clipboard. |
| className? | string | - | - |
Updated