LLayro

Copy Button

Copies text and confirms with a quick icon swap.

Installation

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

Or, with @layro in your components.json:

npx shadcn@latest add @layro/layro/copy-button

Code

import { IconCheck, IconCopy } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

/*
  Copies text and confirms with an icon swap. Also published on Layro as
  `copy-button` by @layro.
*/

export interface CopyButtonProps {
  /** Text placed on the clipboard. */
  value: string;
  /** Visible label. Omit for an icon-only button (then `aria-label` is used). */
  label?: string;
  /** Label shown for a moment after copying. */
  copiedLabel?: string;
  /** Called after a successful copy, e.g. to count it. */
  onCopy?: () => void;
  /** `ghost` drops the button surface: just the icon (and label), for
      sitting directly on code. */
  variant?: "secondary" | "ghost";
  className?: string;
  "aria-label"?: string;
}

export function CopyButton({
  value,
  label,
  copiedLabel = "Copied",
  onCopy,
  variant = "secondary",
  className,
  "aria-label": ariaLabel = "Copy",
}: CopyButtonProps) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<number | undefined>(undefined);

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

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

  return (
    <button
      type="button"
      onClick={copy}
      aria-label={label ? undefined : ariaLabel}
      className={cn(
        "inline-flex h-8 items-center justify-center gap-1.5 rounded-[9px] text-[13px] font-medium",
        variant === "ghost"
          ? "text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-500/60"
          : "layro-cta--secondary layro-cta--sm",
        label ? "px-2.5" : variant === "ghost" ? "w-8" : "px-2.5",
        className,
      )}
    >
      <span className="relative grid size-3.5 place-items-center">
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={copied ? "check" : "copy"}
            initial={{ opacity: 0, scale: 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.6 }}
            transition={{ duration: 0.16, ease: "easeOut" }}
            className="absolute inset-0 grid place-items-center"
          >
            {copied ? (
              <IconCheck size={14} className="text-brand-400" />
            ) : (
              <IconCopy size={14} />
            )}
          </motion.span>
        </AnimatePresence>
      </span>
      {label ? <span>{copied ? copiedLabel : label}</span> : null}
    </button>
  );
}

Versions

  1. 1.0.0 · current

    Oct 27, 2026

    First release.

Similar components