Skip to content
← Components

Copy Button

Source

copy-button.tsx
"use client";

import { useEffect, useRef, useState } from "react";

type CopyButtonProps = {
  value: string;
  label?: string;
  className?: string;
};

/**
 * Copies `value` to the clipboard. The two icons share a slot and cross-fade
 * with a little scale and blur, and the checkmark draws itself in. A visually
 * hidden live region announces the result.
 */
export function CopyButton({ value, label = "Copy to clipboard", className = "" }: CopyButtonProps) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

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

  async function copy() {
    try {
      await navigator.clipboard.writeText(value);
    } catch {
      return;
    }
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1800);
  }

  const icon = "absolute inset-0 m-auto size-4 transition-[opacity,transform,filter] duration-(--duration-enter) ease-out";

  return (
    <button
      type="button"
      onClick={copy}
      aria-label={label}
      className={`relative inline-grid size-8 place-items-center rounded-md text-muted transition-[background-color,color,transform] duration-(--duration-exit) ease-out hover:bg-panel hover:text-ink hover:duration-(--duration-enter) active:scale-[0.92] ${className}`}
    >
      <svg
        aria-hidden
        viewBox="0 0 16 16"
        fill="none"
        className={icon}
        style={{ opacity: copied ? 0 : 1, transform: copied ? "scale(0.5)" : "none", filter: copied ? "blur(3px)" : "none" }}
      >
        <rect x="5.5" y="5.5" width="8" height="8" rx="2" stroke="currentColor" strokeWidth="1.3" />
        <path d="M10.5 3.5v-.25A1.75 1.75 0 0 0 8.75 1.5h-5.5A1.75 1.75 0 0 0 1.5 3.25v5.5c0 .97.78 1.75 1.75 1.75h.25" stroke="currentColor" strokeWidth="1.3" />
      </svg>
      <svg
        aria-hidden
        viewBox="0 0 16 16"
        fill="none"
        className={`${icon} text-accent-strong`}
        style={{ opacity: copied ? 1 : 0, transform: copied ? "none" : "scale(0.5)", filter: copied ? "none" : "blur(3px)" }}
      >
        <path
          d="m3 8.5 3.2 3L13 4.5"
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinecap="round"
          strokeLinejoin="round"
          pathLength={1}
          strokeDasharray={1}
          strokeDashoffset={copied ? 0 : 1}
          className="transition-[stroke-dashoffset] delay-75 duration-(--duration-move) ease-out"
        />
      </svg>
      <span className="sr-only" role="status">
        {copied ? "Copied" : ""}
      </span>
    </button>
  );
}

const command = "npx shadcn add spotlight-card";

export default function Demo() {
  return (
    <div className="flex w-full max-w-sm items-center gap-3 rounded-xl bg-surface py-1.5 pl-4 pr-1.5 font-mono text-[13px] shadow-md">
      <span aria-hidden className="select-none text-subtle">$</span>
      <code className="min-w-0 flex-1 truncate text-ink">{command}</code>
      <CopyButton value={command} label="Copy install command" />
    </div>
  );
}