p441z/Komponen/CopyButton
UI · components/ui/copy-button.tsx

CopyButton

Copy with proof: the icon turns into a self-drawing check.

104 barisframer-motionGitHub ↗
Pasang lewat shadcn CLI

Baru pertama pakai kit ini? Baca panduan pemasangannya

$ npx shadcn@latest add …/copy-button.json

Tentang

CopyButton — copies a value and shows it happened: the clipboard turns away, a check turns in and draws its own stroke, the label rolls to "Copied", and the button squishes under the finger and springs back. It resets on its own and announces the copy to screen readers.

Need the copy on a bigger surface (a whole command row)? Use the parts: `useCopy()` for the state and `CopyGlyph` for the icon, and put `press` classes on your own button (see below).

Cara pakai

<CopyButton value="npx shadcn@latest add …" />                      // icon only
<CopyButton value={cmd} label="Copy" copiedLabel="Copied" />        // icon + rolling label
<CopyButton value={cmd} onCopied={() => notify({ title: "Copied" })} />

const { copied, copy } = useCopy(cmd);
<button onClick={copy} className={cn("…", PRESS)}>… <CopyGlyph copied={copied} /></button>

Ikut terpasang

Kode sumber

components/ui/copy-button.tsx · 104 baris
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";

import { cn } from "@/lib/utils";
import Swap from "@/components/motion/swap";

/**
 * CopyButton — copies a value and shows it happened: the clipboard turns away, a check turns in
 * and draws its own stroke, the label rolls to "Copied", and the button squishes under the finger
 * and springs back. It resets on its own and announces the copy to screen readers.
 *
 * Need the copy on a bigger surface (a whole command row)? Use the parts: `useCopy()` for the
 * state and `CopyGlyph` for the icon, and put `press` classes on your own button (see below).
 *
 * USAGE
 *   <CopyButton value="npx shadcn@latest add …" />                      // icon only
 *   <CopyButton value={cmd} label="Copy" copiedLabel="Copied" />        // icon + rolling label
 *   <CopyButton value={cmd} onCopied={() => notify({ title: "Copied" })} />
 *
 *   const { copied, copy } = useCopy(cmd);
 *   <button onClick={copy} className={cn("…", PRESS)}>… <CopyGlyph copied={copied} /></button>
 */

/** Squish on press, spring back on release — put it on any clickable surface. */
export const PRESS = "transition-[scale] duration-300 ease-spring active:scale-[.94] active:duration-100 motion-reduce:active:scale-100";

/** Clipboard state: `copy()` writes, `copied` stays true for `timeout` ms. */
export function useCopy(value: string, { timeout = 1600, onCopied }: { timeout?: number; onCopied?: () => void } = {}) {
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef(0);
  React.useEffect(() => () => window.clearTimeout(timer.current), []);
  const copy = React.useCallback(async () => {
    try {
      await navigator.clipboard.writeText(value);
    } catch {
      return; // clipboard blocked (insecure origin, permissions) — say nothing rather than lie
    }
    setCopied(true);
    onCopied?.();
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setCopied(false), timeout);
  }, [value, timeout, onCopied]);
  return { copied, copy };
}

/** The clipboard ↔ check icon. The check draws itself in as it turns. */
export function CopyGlyph({ copied, className }: { copied: boolean; className?: string }) {
  const reduce = useReducedMotion();
  return (
    <Swap id={copied ? "done" : "idle"} mode="turn" className={cn("size-4 [&_svg]:size-full", className)}>
      {copied ? (
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
          <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35, delay: 0.08, ease: [0.22, 1, 0.36, 1] }} />
        </svg>
      ) : (
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
          <rect x="9" y="9" width="12" height="12" rx="2.5" />
          <path d="M5 15H4.5A1.5 1.5 0 0 1 3 13.5v-9A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5V5" />
        </svg>
      )}
    </Swap>
  );
}

interface CopyButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "value"> {
  value: string;
  /** Visible label. Without it the button is icon-only and named "Copy" for screen readers. */
  label?: string;
  copiedLabel?: string;
  onCopied?: () => void;
  timeout?: number;
}

export default function CopyButton({ value, label, copiedLabel = "Copied", onCopied, timeout, className, ...rest }: CopyButtonProps) {
  const { copied, copy } = useCopy(value, { timeout, onCopied });
  return (
    <button
      type="button"
      onClick={copy}
      aria-label={label ? undefined : copied ? copiedLabel : "Copy"}
      className={cn(
        "inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
        label ? "h-8 px-2.5" : "size-8",
        copied ? "text-primary" : "text-muted-foreground hover:text-foreground",
        PRESS,
        className,
      )}
      {...rest}
    >
      <CopyGlyph copied={copied} />
      {label && (
        <Swap id={copied ? "done" : "idle"} mode="roll">
          {copied ? copiedLabel : label}
        </Swap>
      )}
      <span aria-live="polite" className="sr-only">
        {copied ? copiedLabel : ""}
      </span>
    </button>
  );
}