npm i @aimran/ui
React + MotionMicrointeracciones

Botón de copiar comando

Al copiar, un subrayado de selección barre el comando y el icono se convierte en un check que se dibuja solo.

Ajustes

Código

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 ? "Copiado" : "Copiar comando"}
        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 ? "Copiado al portapapeles" : ""}
      </span>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Microinteracciones

Dónde usarlo

Un botón de copiar al portapapeles es de esos detalles que solo se notan cuando faltan. Va junto al comando de instalación de tu documentación, una clave de API en un panel, un código de descuento o un enlace de invitación. El barrido sobre el texto enseña justo lo que se ha copiado, y los lectores de pantalla oyen «Copiado».

Los navegadores solo dejan copiar en páginas con HTTPS; en HTTP se ve la animación, pero no copia nada. Si tu comando es largo, se cortará con puntos suspensivos; en ese caso, dale más ancho a la caja. El check verde se entiende como éxito en casi todas partes.