Web InnoventixFreeCode

Copy Button

Original · free

A copy-to-clipboard button with a copied confirmation state.

byWeb InnoventixReact + Tailwind
btncopybuttons
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/btn-copy.json
btn-copy.tsx
"use client";

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

type CopyState = "idle" | "copied" | "error";

function useCopy(text: string, resetMs = 2000) {
  const [state, setState] = useState<CopyState>("idle");
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    return () => {
      if (timer.current) clearTimeout(timer.current);
    };
  }, []);

  const copy = useCallback(async () => {
    if (timer.current) clearTimeout(timer.current);
    try {
      if (navigator?.clipboard?.writeText) {
        await navigator.clipboard.writeText(text);
      } else {
        const ta = document.createElement("textarea");
        ta.value = text;
        ta.setAttribute("readonly", "");
        ta.style.position = "fixed";
        ta.style.opacity = "0";
        document.body.appendChild(ta);
        ta.select();
        document.execCommand("copy");
        document.body.removeChild(ta);
      }
      setState("copied");
    } catch {
      setState("error");
    }
    timer.current = setTimeout(() => setState("idle"), resetMs);
  }, [text, resetMs]);

  return { state, copy };
}

function CopyGlyph() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <rect x="9" y="9" width="11" height="11" rx="2" />
      <path d="M6.5 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1.5" />
    </svg>
  );
}

function CheckGlyph() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.4}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path
        d="M5 13l4 4L19 7"
        className="btncopy-check-path"
        pathLength={1}
      />
    </svg>
  );
}

function ErrorGlyph() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M12 8v4M12 16h.01" />
    </svg>
  );
}

/* ---------- Variant 1: default labelled button ---------- */

function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
  const { state, copy } = useCopy(text);
  const copied = state === "copied";
  const errored = state === "error";

  return (
    <button
      type="button"
      onClick={copy}
      aria-live="polite"
      className={[
        "group relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl px-4 py-2.5 text-sm font-semibold",
        "transition-all duration-200 ease-out active:translate-y-px",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        copied
          ? "bg-emerald-600 text-white ring-emerald-400 focus-visible:ring-emerald-500"
          : errored
            ? "bg-rose-600 text-white ring-rose-400 focus-visible:ring-rose-500"
            : "bg-slate-900 text-white shadow-sm shadow-slate-900/20 hover:bg-slate-800 focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-slate-300",
      ].join(" ")}
    >
      <span className="relative grid h-4 w-4 place-items-center">
        <span
          className={[
            "col-start-1 row-start-1 transition-all duration-200",
            copied || errored ? "scale-50 opacity-0" : "scale-100 opacity-100",
          ].join(" ")}
        >
          <CopyGlyph />
        </span>
        <span
          className={[
            "col-start-1 row-start-1 transition-all duration-200",
            copied ? "scale-100 opacity-100" : "scale-50 opacity-0",
          ].join(" ")}
        >
          <CheckGlyph />
        </span>
        <span
          className={[
            "col-start-1 row-start-1 transition-all duration-200",
            errored ? "scale-100 opacity-100" : "scale-50 opacity-0",
          ].join(" ")}
        >
          <ErrorGlyph />
        </span>
      </span>
      <span className="tabular-nums">
        {copied ? "Copied!" : errored ? "Failed" : label}
      </span>
    </button>
  );
}

/* ---------- Variant 2: icon-only ghost button ---------- */

function CopyIconButton({ text }: { text: string }) {
  const { state, copy } = useCopy(text);
  const copied = state === "copied";
  const errored = state === "error";

  return (
    <button
      type="button"
      onClick={copy}
      aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
      title={copied ? "Copied" : "Copy"}
      className={[
        "inline-flex h-10 w-10 items-center justify-center rounded-xl border transition-all duration-200 active:translate-y-px",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        copied
          ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 focus-visible:ring-emerald-500 dark:text-emerald-400"
          : errored
            ? "border-rose-500/40 bg-rose-500/10 text-rose-600 focus-visible:ring-rose-500 dark:text-rose-400"
            : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900 focus-visible:ring-slate-400 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white",
      ].join(" ")}
    >
      {copied ? <CheckGlyph /> : errored ? <ErrorGlyph /> : <CopyGlyph />}
    </button>
  );
}

/* ---------- Variant 3: code block with corner copy ---------- */

function CopyCodeBlock({ label, code }: { label: string; code: string }) {
  const { state, copy } = useCopy(code);
  const copied = state === "copied";

  return (
    <div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
      <div className="flex items-center justify-between border-b border-slate-200 bg-white/70 px-4 py-2 dark:border-slate-800 dark:bg-slate-900/80">
        <div className="flex items-center gap-2">
          <span className="flex gap-1.5">
            <span className="h-2.5 w-2.5 rounded-full bg-rose-400" />
            <span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
            <span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
          </span>
          <span className="ml-1 font-mono text-xs text-slate-500 dark:text-slate-400">
            {label}
          </span>
        </div>
        <button
          type="button"
          onClick={copy}
          aria-label={copied ? "Copied code" : "Copy code"}
          className={[
            "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-all duration-200 active:translate-y-px",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
            "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
            copied
              ? "text-emerald-600 focus-visible:ring-emerald-500 dark:text-emerald-400"
              : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-slate-400 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
          ].join(" ")}
        >
          {copied ? <CheckGlyph /> : <CopyGlyph />}
          <span>{copied ? "Copied" : "Copy"}</span>
        </button>
      </div>
      <pre className="overflow-x-auto px-4 py-3.5 font-mono text-[13px] leading-relaxed text-slate-800 dark:text-slate-200">
        <code>{code}</code>
      </pre>
    </div>
  );
}

/* ---------- Variant 4: inline token / API-key pill ---------- */

function CopyInlinePill({ value }: { value: string }) {
  const { state, copy } = useCopy(value);
  const copied = state === "copied";

  return (
    <div className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white py-1 pl-3.5 pr-1 shadow-sm dark:border-slate-800 dark:bg-slate-900">
      <code className="font-mono text-sm text-slate-700 dark:text-slate-200">
        {value}
      </code>
      <button
        type="button"
        onClick={copy}
        aria-label={copied ? "Copied value" : `Copy ${value}`}
        className={[
          "inline-flex h-7 w-7 items-center justify-center rounded-full transition-all duration-200 active:translate-y-px",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1",
          "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
          copied
            ? "bg-emerald-500/15 text-emerald-600 focus-visible:ring-emerald-500 dark:text-emerald-400"
            : "text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100",
        ].join(" ")}
      >
        {copied ? <CheckGlyph /> : <CopyGlyph />}
      </button>
    </div>
  );
}

/* ---------- Variant 5: accent gradient CTA ---------- */

function CopyAccentButton({ text }: { text: string }) {
  const { state, copy } = useCopy(text);
  const copied = state === "copied";

  return (
    <button
      type="button"
      onClick={copy}
      aria-live="polite"
      className={[
        "relative inline-flex items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold text-white",
        "transition-all duration-200 active:translate-y-px",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        copied
          ? "bg-emerald-600 focus-visible:ring-emerald-500"
          : "bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-violet-500/25 hover:from-indigo-500 hover:to-violet-500 focus-visible:ring-violet-500",
      ].join(" ")}
    >
      {copied ? <CheckGlyph /> : <CopyGlyph />}
      <span>{copied ? "Copied to clipboard" : "Copy invite link"}</span>
    </button>
  );
}

export default function BtnCopy() {
  return (
    <section className="relative w-full bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-8">
      <style>{`
        .btncopy-check-path {
          stroke-dasharray: 1;
          stroke-dashoffset: 1;
          animation: btncopy-draw 260ms ease-out forwards;
        }
        @keyframes btncopy-draw {
          to { stroke-dashoffset: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .btncopy-check-path { animation: none; stroke-dashoffset: 0; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-12">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            buttons / btn-copy
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight sm:text-3xl">
            Copy to clipboard
          </h2>
          <p className="mt-2 max-w-prose text-sm leading-relaxed text-slate-500 dark:text-slate-400">
            Tactile copy buttons with an animated confirmation state. Real
            clipboard writes, focus-visible rings, and graceful failure
            feedback.
          </p>
        </header>

        <div className="space-y-10">
          <div>
            <p className="mb-3 text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
              Labelled & icon-only
            </p>
            <div className="flex flex-wrap items-center gap-3">
              <CopyButton text="npm install motion" />
              <CopyIconButton text="npm install motion" />
              <CopyAccentButton text="https://freecode.dev/i/8fK2p" />
            </div>
          </div>

          <div>
            <p className="mb-3 text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
              Code block
            </p>
            <CopyCodeBlock
              label="terminal"
              code={`pnpm add motion\npnpm dlx shadcn@latest add button`}
            />
          </div>

          <div>
            <p className="mb-3 text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
              Inline token
            </p>
            <div className="flex flex-wrap items-center gap-3">
              <CopyInlinePill value="sk_live_51Nc8Qe7" />
              <CopyInlinePill value="#6366F1" />
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →