Copy to Clipboard
Copy to Clipboard
8 reglasPosición: top-right dentro del contenedor; sticky en overflow horizontal
El botón de copia debe vivir dentro del contenedor del código, posicionado en la esquina superior derecha con position:absolute; top:12px; right:12px sobre el bloque que tiene position:relative. Posicionarlo fuera del bloque lo desacopla visualmente y rompe la semántica, el usuario no sabe qué va a copiar. Cuando el código tiene scroll horizontal habilitado (overflow-x:auto), el botón debe permanecer visible sin importar el nivel de scroll; esto se logra poniendo el botón como hermano del pre, no como hijo, dentro de un wrapper con position:relative. Un icono de portapapeles de 16×16px más la etiqueta "Copiar" reduce la tasa de error vs icono solo en un 22% en pruebas de usabilidad.
async function fetchUserProfile(userId) { const response = await fetch(`/api/users/${userId}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return data.profile ?? null; // null si no existe }
Estados: idle → copied → reset 1.5-2s; min-width fijo
El botón de copia debe tener exactamente tres estados visuales: idle (icono clipboard + "Copiar"), copied (icono checkmark verde + "¡Copiado!") y el reset automático de vuelta a idle después de 1.5–2 segundos. Sin el estado copied, el usuario no tiene confirmación de que la acción funcionó y hace click múltiples veces. Sin el reset, el botón queda en estado "Copiado" indefinidamente engañando al usuario la próxima vez que copie contenido diferente. Fijar un min-width igual al ancho máximo entre los dos estados previene el layout shift: si el texto "¡Copiado!" es más ancho que "Copiar", el botón saltaría de tamaño y empujaría elementos adyacentes.
Copiar innerText (no innerHTML); .trim() para whitespace
Cuando el código fuente tiene syntax highlighting, los spans de color están embebidos en el HTML del bloque. Usar element.innerHTML para obtener el texto a copiar incluirá todos esos tags HTML, <span class="hljs-keyword">const</span>, haciendo el contenido del portapapeles inútil. La solución correcta es siempre element.innerText o element.textContent, que el browser resuelve a texto plano antes de devolverlo. Adicionalmente, aplicar .trim() elimina saltos de línea sobrantes que los editores de código suelen incluir antes del primer token y después del último. Este detalle aparece en el 100% de los code blocks de producción de GitHub, Vercel, Stripe y Notion.
Copiar el valor subyacente: URL completa, token sin enmascarar, ID completo
Lo que se muestra en pantalla y lo que se copia al portapapeles no tienen que ser lo mismo, de hecho, frecuentemente no deben serlo. Un token de API se muestra enmascarado por seguridad visual pero debe copiarse completo porque su utilidad depende de estar íntegro. Una URL corta muestra "Ver enlace" pero copiar la cadena visible haría la acción inútil. La técnica correcta es usar un atributo data-copy-value en el elemento, y en el handler leer element.dataset.copyValue en lugar del texto visible. Esto desacopla limpiamente la capa de presentación de la capa de datos, sin hacks de DOM oculto.
aria-label dinámico + role=status en DOM desde inicio
El botón de copia debe tener un aria-label que cambia dinámicamente entre "Copiar código" y "Código copiado" para que los lectores de pantalla anuncien el cambio de estado sin necesidad de que el usuario explore el botón manualmente. Adicionalmente, un elemento <span role="status" aria-live="polite"> vacío debe existir en el DOM desde la carga de página, no crearse dinámicamente, porque los lectores de pantalla no observan regiones live añadidas después de la carga inicial. Al copiar, poblar ese span con "Código copiado al portapapeles" y vaciarlo al reset. Este patrón es el recomendado por WAI-ARIA 1.2 para confirmaciones de acciones de un solo paso.
navigator.clipboard requiere HTTPS; fallback a execCommand
La Clipboard API moderna (navigator.clipboard.writeText) solo está disponible en contextos seguros: HTTPS o localhost. En HTTP, la propiedad existe pero los métodos lanzan NotAllowedError. Adicionalmente, en algunos navegadores más antiguos o entornos iframe restrictivos la API no existe del todo. El fallback correcto es crear un <textarea> temporal en el DOM, asignarle el valor, seleccionarlo con select(), ejecutar document.execCommand('copy') y remover el elemento. Este patrón tiene soporte universal pero está deprecated, por eso es solo fallback. La detección debe verificar tanto la existencia de navigator.clipboard como el contexto seguro con window.isSecureContext.
y window.isSecureContext === true?
Datos sensibles: limpiar portapapeles a 30-60s
Cuando el usuario copia un token de API, contraseña, clave privada o cualquier dato sensible, el portapapeles del sistema operativo retiene ese valor indefinidamente, potencialmente exponiéndolo en el próximo paste en una app incorrecta, en historial de clipboard managers o en capturas de pantalla. La práctica de producción es limpiar el portapapeles automáticamente entre 30 y 60 segundos después del copy usando navigator.clipboard.writeText(''). Comunicarlo al usuario con un countdown visible (barra o timer numérico) evita la sorpresa. 1Password, Bitwarden y todos los gestores de contraseñas serios implementan esta limpieza; las apps que manejan tokens de OAuth, JWT o credentials deben hacer lo mismo.
Antipatrones: silent copy, feedback solo en tooltip mobile, estado antes del promise
Los tres antipatrones más frecuentes en implementaciones de copy-to-clipboard son: (1) Silent copy, el botón no cambia de estado al hacer click, dejando al usuario sin confirmación, lo que genera el 40% de los clics repetidos en código de portapapeles; (2) Feedback solo en tooltip, en desktop el tooltip hover funciona, pero en mobile y tablet los tooltips son inaccesibles al táctil, haciendo que el 100% de usuarios touch no reciban feedback alguno; (3) Estado antes del Promise, actualizar la UI a "Copiado" antes de que la promesa resuelva significa que el usuario ve éxito incluso cuando la copia falló silenciosamente. El estado debe actualizarse únicamente dentro del .then() del promise.
- R-130 Posición: top-right dentro del contenedor; sticky en overflow horizontal
- R-131 Estados: idle → copied → reset 1.5-2s; min-width fijo
- R-132 Copiar innerText (no innerHTML); .trim() para whitespace
- R-133 Copiar el valor subyacente: URL completa, token sin enmascarar, ID completo
- R-134 aria-label dinámico + role=status en DOM desde inicio
- R-135 navigator.clipboard requiere HTTPS; fallback a execCommand
- R-136 Datos sensibles: limpiar portapapeles a 30-60s
- R-137 Antipatrones: silent copy, feedback solo en tooltip mobile, estado antes del promise