Product Craft Bible
Copy to Clipboard
Inicio Componentes UI Copy to Clipboard
Componentes UI

Copy to Clipboard

8 reglas GitHub code blocks · Vercel Docs copy button · Docusaurus v3 CodeBlock · Linear Changelog · NNGroup "Icon Usability" 2014Stripe Dashboard copy button · Vercel docs token copy · Linear shortcut copy · GitHub Copilot UI patterns · NNGroup "Feedback Timing" 2023MDN Web Docs "innerText vs textContent" · GitHub Primer CodeBlock · Stripe Docs copy handler · Highlight.js docs · Prism.js integration guideStripe API Dashboard token copy · GitHub Personal Access Tokens · Vercel Environment Variables UI · Linear share URL copy · Notion page link copy
12

Copy to Clipboard

8 reglas
130

Posició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.

GitHub code blocks · Vercel Docs copy button · Docusaurus v3 CodeBlock · Linear Changelog · NNGroup "Icon Usability" 2014
Preferir
JS
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
}
131

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.

Stripe Dashboard copy button · Vercel docs token copy · Linear shortcut copy · GitHub Copilot UI patterns · NNGroup "Feedback Timing" 2023
Preferir
1. Idle
2. Copied
3. Reset (1.8s)
min-width: 110px Fijado al ancho del estado más ancho para que ningún cambio cause layout shift.
Botón interactivo
132

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.

MDN Web Docs "innerText vs textContent" · GitHub Primer CodeBlock · Stripe Docs copy handler · Highlight.js docs · Prism.js integration guide
Preferir
innerHTML, Evitar
const text = el.innerHTML;
Qué llega al portapapeles
<span class="kw">const</span> name = <span class="str">"Alex"</span>;
innerText.trim(), Preferir
const text = el.innerText.trim();
Qué llega al portapapeles
const name = "Alex";
.innerHTML, incluye tags HTML
.innerText.trim(), texto limpio
133

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.

Stripe API Dashboard token copy · GitHub Personal Access Tokens · Vercel Environment Variables UI · Linear share URL copy · Notion page link copy
Preferir
API Token
sk-••••••••••••1234
Copia: sk-live_a1b2c3d4e5f6g7h8i9j0K1L2...
URL corta
Ver enlace →
Copia: https://ejemplo.com/productos/categoría/laptops?ref=promo2026
ID de recurso
#USR-8821
Copia: a1b2c3d4-e5f6-7890-abcd-ef1234567890
data-copy-value="sk-live_full_token..." handler: el.dataset.copyValue // no el.innerText
134

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.

WAI-ARIA 1.2 "aria-live regions" · Docusaurus a11y CodeBlock · React Aria useClipboard · Inclusive Components "Notifications" · Deque University clipboard pattern
Preferir
HTML + JS
<!-- Botón con aria-label dinámico --> <button id="copy-btn" aria-label="Copiar código" ← cambia al copiar >Copiar</button> <!-- Región live en DOM desde el inicio, vacía --> <span role="status" aria-live="polite" class="sr-only" ← visualmente oculto id="copy-status" ></span> ← vacío en carga inicial
copyBtn.addEventListener('click', async () => { await navigator.clipboard.writeText(code); // 1. Actualizar aria-label del botón btn.setAttribute('aria-label', 'Código copiado'); // 2. Poblar región live status.textContent = 'Código copiado al portapapeles'; setTimeout(() => { btn.setAttribute('aria-label', 'Copiar código'); status.textContent = ''; // limpiar }, 2000); });
135

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.

MDN "Clipboard API" · W3C Secure Contexts spec · Can I Use Clipboard API 2024 · clipboard-copy npm package · React copy-to-clipboard internals
Preferir
Usuario hace click en "Copiar"
¿navigator.clipboard disponible
y window.isSecureContext === true?
Sí (HTTPS)
writeText(text) .then(showCopied) .catch(fallback)
No (HTTP / antiguo)
textarea temporal + execCommand('copy') + removeChild()
showCopied(), feedback visual + ARIA
136

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.

1Password security whitepaper 2024 · Bitwarden clipboard clear timeout · OWASP "Sensitive Data in Clipboard" · GitHub token copy behavior · Stripe Dashboard API keys
Preferir
Claves de API
Secret Key
API Secret sk-••••••••••••••••••••2F9A
Portapapeles se limpiará en
5s
137

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.

NNGroup "Preventing User Errors" 2024 · MDN async clipboard errors · Inclusive Design "Tooltips on Touch" · Linear bug tracker clipboard reports · Stripe UX audit 2023
Evitar
Antipatrón 1 Silent copy, sin feedback de confirmación
npm i @acme/core
Copiar
click, click, click...
El botón no cambia al hacer click. El usuario no sabe si la acción funcionó y repite el click 2-3 veces, generando el 40% de clics redundantes en portapapeles.
Antipatrón 2 Feedback solo en tooltip, invisible en touch
Desktop
Copiado
Copiar
Mobile
×
sin hover, sin feedback
En desktop el tooltip hover funciona. En mobile no existe el hover: el 100% de usuarios táctiles nunca recibe confirmación visual de la acción.
Antipatrón 3 Estado "Copiado" antes de que el Promise resuelva
// mal: UI antes del resultado btn.text = 'Copiado'; falso positivo navigator.clipboard .writeText(text) .then(showCopied);
El botón dice "Copiado" antes de saber si la operación tuvo éxito. Si el Promise falla, el usuario cree que copió algo que nunca llegó al portapapeles.