Talisson Costa
← All components

Copy Button

Icon morphs from copy to check with a blur-scale crossfade, then the check draws itself.

  • AnimatePresence popLayout
  • spring
  • pathLength
  • blur
npx shadcn add @tcosta/copy-button

Install

npx shadcn@latest add https://tcosta.dev/r/copy-button.json

Or copy the files below into components/copy-button/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion.

Source

copy-button.tsx
'use client';

import { AnimatePresence, motion, type Variants } from 'motion/react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from './icons';
import { useCopyToClipboard } from './use-copy-to-clipboard';

type CopyButtonProps = Omit<ComponentProps<'button'>, 'onClick' | 'children'> & {
  value: string;
  resetAfter?: number;
  onCopy?: (value: string) => void;
};

const iconVariants: Variants = {
  hidden: { opacity: 0, scale: 0.5, filter: 'blur(4px)' },
  visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },
};

export function CopyButton({
  value,
  resetAfter = 1800,
  onCopy,
  className,
  ...props
}: CopyButtonProps) {
  const { copied, copy } = useCopyToClipboard({ resetAfter, onCopy });

  return (
    <button
      type="button"
      onClick={() => copy(value)}
      aria-label={copied ? 'Copied' : 'Copy to clipboard'}
      className={cn(
        'relative inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors outline-none',
        'hover:bg-accent hover:text-accent-foreground',
        'focus-visible:ring-[3px] focus-visible:ring-ring/50',
        'active:scale-[0.97]',
        className,
      )}
      {...props}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={copied ? 'check' : 'copy'}
          variants={iconVariants}
          initial="hidden"
          animate="visible"
          exit="hidden"
          transition={{ type: 'spring', duration: 0.3, bounce: 0.25 }}
          className="flex"
        >
          {copied ? <CheckIcon /> : <CopyIcon />}
        </motion.span>
      </AnimatePresence>
      <span className="sr-only" aria-live="polite">
        {copied ? 'Copied to clipboard' : ''}
      </span>
    </button>
  );
}
use-copy-to-clipboard.ts
import { useEffect, useRef, useState } from 'react';

type UseCopyToClipboardOptions = {
  resetAfter: number;
  onCopy?: (value: string) => void;
};

export function useCopyToClipboard({ resetAfter, onCopy }: UseCopyToClipboardOptions) {
  const [copied, setCopied] = useState(false);
  const timeout = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => () => clearTimeout(timeout.current), []);

  const copy = async (value: string) => {
    try {
      await navigator.clipboard.writeText(value);
    } catch {
      return; // Clipboard blocked (e.g. insecure context): don't fake a "copied".
    }
    onCopy?.(value);
    setCopied(true);
    clearTimeout(timeout.current);
    timeout.current = setTimeout(() => setCopied(false), resetAfter);
  };

  return { copied, copy };
}
icons.tsx
import { motion } from 'motion/react';
import type { ComponentProps } from 'react';

function Icon({ children, ...props }: ComponentProps<'svg'>) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    >
      {children}
    </svg>
  );
}

export function CopyIcon() {
  return (
    <Icon>
      <rect x="9" y="9" width="13" height="13" rx="2" />
      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
    </Icon>
  );
}

export function CheckIcon() {
  return (
    <Icon strokeWidth="2.5">
      <motion.path
        d="M20 6 9 17l-5-5"
        initial={{ pathLength: 0 }}
        animate={{ pathLength: 1 }}
        transition={{ duration: 0.25, ease: 'easeOut', delay: 0.05 }}
      />
    </Icon>
  );
}