npm i @aimran/ui
React + MotionMicro-interactions

Bouton copier la commande

À la copie, un surlignage balaie la commande et l’icône se change en coche qui se dessine toute seule.

Réglages

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Copy } from "lucide-react";

interface CopyCommandProps {
  command?: string;
  accent?: string;
}

export default function CopyCommand({
  command = "npm i @aimran/ui",
  accent = "#22c55e",
}: CopyCommandProps) {
  const [copied, setCopied] = useState(false);
  const [sweep, setSweep] = useState(0);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const reduce = useReducedMotion();

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

  const copy = () => {
    try {
      navigator.clipboard?.writeText(command).catch(() => {});
    } catch {
      // Sin portapapeles disponible: la animación sigue funcionando
    }
    setCopied(true);
    setSweep((s) => s + 1);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1800);
  };

  return (
    <div className="flex w-full max-w-[19rem] items-center gap-3 rounded-2xl bg-white py-1.5 pl-4 pr-1.5 font-mono text-[13px] text-[#1d1d1f] shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06)] dark:bg-[#1c1c1e] dark:text-[#f5f5f7] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.08),0_8px_24px_rgba(0,0,0,0.4)]">
      <span className="select-none text-[#86868b]" aria-hidden="true">
        $
      </span>
      <span className="relative min-w-0 flex-1">
        {sweep > 0 && !reduce && (
          <motion.span
            key={sweep}
            aria-hidden="true"
            className="absolute -inset-x-1 -inset-y-0.5 origin-left rounded-[5px]"
            style={{ backgroundColor: accent }}
            initial={{ scaleX: 0, opacity: 0.24 }}
            animate={{ scaleX: 1, opacity: [0.24, 0.24, 0] }}
            transition={{
              scaleX: { duration: 0.34, ease: [0.22, 1, 0.36, 1] },
              opacity: { duration: 1.1, times: [0, 0.55, 1] },
            }}
          />
        )}
        <span className="relative block truncate">{command}</span>
      </span>
      <motion.button
        type="button"
        onClick={copy}
        aria-label={copied ? "Copié" : "Copier la commande"}
        whileTap={{ scale: 0.88 }}
        transition={{ type: "spring", stiffness: 600, damping: 30 }}
        className="relative grid h-9 w-9 shrink-0 place-items-center rounded-xl text-[#86868b] outline-none transition-colors hover:bg-black/5 hover:text-[#1d1d1f] focus-visible:ring-2 focus-visible:ring-black/20 dark:hover:bg-white/10 dark:hover:text-[#f5f5f7] dark:focus-visible:ring-white/30"
      >
        <motion.span
          aria-hidden="true"
          className="absolute inset-0 rounded-xl"
          style={{ backgroundColor: accent }}
          initial={false}
          animate={{ opacity: copied ? 0.14 : 0 }}
          transition={{ duration: 0.2 }}
        />
        <AnimatePresence mode="popLayout" initial={false}>
          {copied ? (
            <motion.svg
              key="check"
              viewBox="0 0 24 24"
              className="relative h-[18px] w-[18px]"
              fill="none"
              stroke={accent}
              strokeWidth={2.25}
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={{ opacity: 0, scale: 0.5 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
              transition={{ type: "spring", stiffness: 500, damping: 26 }}
            >
              <motion.path
                d="M5 12.5l4.5 4.5L19 7.5"
                initial={{ pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 0.3, delay: 0.05, ease: [0.65, 0, 0.35, 1] }}
              />
            </motion.svg>
          ) : (
            <motion.span
              key="copy"
              className="relative grid place-items-center"
              initial={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
              transition={{ type: "spring", stiffness: 500, damping: 30 }}
            >
              <Copy className="h-[18px] w-[18px]" strokeWidth={1.5} />
            </motion.span>
          )}
        </AnimatePresence>
      </motion.button>
      <span className="sr-only" aria-live="polite">
        {copied ? "Copié dans le presse-papiers" : ""}
      </span>
    </div>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Micro-interactions

Où l’utiliser

Un bouton copier dans le presse-papiers fait partie des détails qu’on remarque surtout quand ils manquent. Il a sa place à côté de la commande d’installation d’une documentation, d’une clé d’API dans un tableau de bord, d’un code promo ou d’un lien de parrainage. Le balayage montre ce qui a été copié, et les lecteurs d’écran annoncent « Copié ».

Les navigateurs n’autorisent la copie que sur des pages en HTTPS ; en simple HTTP, l’animation se joue mais rien n’est copié. Les commandes longues sont tronquées ; si la vôtre l’est, élargissez la boîte. La coche verte se lit comme un succès presque partout.