Clipboard API con Claude Code: botones de copiar, permisos, fallback y tests
Implementa Clipboard API con Claude Code: copiar, pegar, permisos, fallback, React, privacidad y Playwright.
La Clipboard API parece una mejora pequeña: un botón llama a navigator.clipboard.writeText() y el usuario copia texto. En producción no es tan simple. Puede fallar en una vista previa HTTP, dentro de un iframe, después de un fetch lento, con permisos diferentes entre navegadores o en un flujo de Safari móvil que exige una interacción reciente del usuario.
Esta guía muestra cómo pedirle a Claude Code una implementación lista para usar en React y TypeScript. Clipboard API es la API del navegador para leer y escribir en el portapapeles del sistema operativo. Async Clipboard es la versión basada en Promises. Secure context significa un entorno que el navegador considera confiable, como HTTPS o localhost. Fallback es el camino alternativo que probamos cuando la API moderna no está disponible.
Para ampliar el tema, revisa accesibilidad con Claude Code, tests Playwright con Claude Code y validación de formularios con Claude Code. Ten a mano las fuentes oficiales: MDN Clipboard API, MDN writeText, W3C Clipboard API and events, Playwright BrowserContext, WebKit Async Clipboard API y Claude Code docs.
Define criterios antes de implementar
No le pidas a Claude Code solo “haz un botón de copiar”. Dale los casos que deben funcionar y los límites que no debe cruzar.
Goal: Implement Clipboard API copy and paste UX in React.
Scope: edit only src/lib/clipboard.ts, src/components/CopyButton.tsx, and matching tests.
Requirements:
- Use navigator.clipboard.writeText in secure contexts.
- Keep the write call inside a user click handler.
- Provide a textarea fallback for unsupported or HTTP pages.
- Never read clipboard on page load.
- Show accessible copied/error feedback.
- Add Playwright tests for copy success and paste normalization.
Do not stage, commit, or edit unrelated files.
El flujo que buscamos es este.
flowchart TD
A["El usuario pulsa copiar"] --> B{"Async Clipboard disponible?"}
B -->|yes| C["writeText"]
B -->|no| D["textarea + execCommand fallback"]
C --> E{"Funcionó?"}
D --> E
E -->|yes| F["Anunciar copiado con aria-live"]
E -->|no| G["Mostrar ayuda para copiar manualmente"]
H["El usuario pega de forma explícita"] --> I["readText u onPaste"]
I --> J["Normalizar, limitar, validar"]
La regla de privacidad es clara: no leas el portapapeles al cargar la página ni como acción oculta. Puede contener contraseñas, direcciones, enlaces internos, datos de clientes o código privado. La lectura debe ocurrir solo por un botón visible de pegar o por el evento normal onPaste.
Extrae la lógica de copia
Primero crea una utilidad independiente de React. Así los fallbacks, errores y tests quedan en un solo sitio.
// src/lib/clipboard.ts
export type CopyResult =
| { ok: true; method: "async-clipboard" | "textarea-fallback" }
| { ok: false; method: "async-clipboard" | "textarea-fallback" | "unsupported"; error: string };
export async function copyText(text: string): Promise<CopyResult> {
if (!text) {
return { ok: false, method: "unsupported", error: "Copy text is empty." };
}
if (canUseAsyncClipboard()) {
try {
await navigator.clipboard.writeText(text);
return { ok: true, method: "async-clipboard" };
} catch (error) {
const fallback = fallbackCopyText(text);
if (fallback) return { ok: true, method: "textarea-fallback" };
return {
ok: false,
method: "async-clipboard",
error: error instanceof Error ? error.message : "Clipboard write was blocked.",
};
}
}
if (fallbackCopyText(text)) {
return { ok: true, method: "textarea-fallback" };
}
return {
ok: false,
method: "unsupported",
error: "Clipboard API is unavailable in this browser or context.",
};
}
function canUseAsyncClipboard(): boolean {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
typeof navigator !== "undefined" &&
Boolean(navigator.clipboard?.writeText)
);
}
function fallbackCopyText(text: string): boolean {
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
const selection = document.getSelection();
const selectedRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
if (selection && selectedRange) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
}
}
document.execCommand("copy") está obsoleto, así que no debe ser la ruta principal. Sigue siendo útil como último recurso en navegadores antiguos, webviews restringidas o páginas HTTP de prueba. También puede fallar si no se ejecuta dentro de una acción del usuario.
Hook React y botón accesible
El hook gestiona los estados de copiar, copiado y error. El componente anuncia el resultado con role="status" y aria-live.
// src/components/CopyButton.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { copyText, type CopyResult } from "../lib/clipboard";
type ClipboardStatus = "idle" | "copying" | "copied" | "failed";
export function useClipboard(resetAfter = 2000) {
const [status, setStatus] = useState<ClipboardStatus>("idle");
const [message, setMessage] = useState("");
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, []);
const copy = useCallback(
async (text: string): Promise<CopyResult> => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setStatus("copying");
setMessage("Copying...");
const result = await copyText(text);
if (result.ok) {
setStatus("copied");
setMessage("Copied to clipboard.");
} else {
setStatus("failed");
setMessage("Copy failed. Select the text and copy it manually.");
}
timerRef.current = window.setTimeout(() => {
setStatus("idle");
setMessage("");
}, resetAfter);
return result;
},
[resetAfter],
);
return { copy, status, message };
}
type CopyButtonProps = {
text: string;
label?: string;
copiedLabel?: string;
className?: string;
};
export function CopyButton({
text,
label = "Copy",
copiedLabel = "Copied",
className = "",
}: CopyButtonProps) {
const { copy, status, message } = useClipboard();
const isCopying = status === "copying";
return (
<div className="inline-flex items-center gap-2">
<button
type="button"
className={className}
onClick={() => void copy(text)}
disabled={isCopying}
aria-label={status === "copied" ? copiedLabel : label}
>
{status === "copied" ? copiedLabel : label}
</button>
<span role="status" aria-live="polite" className="sr-only">
{message}
</span>
</div>
);
}
El texto visible no basta. Quien usa lector de pantalla también necesita saber si la acción funcionó. Además, la región de estado es un punto de verificación estable para Playwright.
Copia de bloques de código sin saltos visuales
El caso más común es el bloque de código en documentación o artículos. En una primera versión de ClaudeCodeLab, Masa detectó que el botón pasaba de “Copy” a “Copied”, se hacía más ancho y movía el layout. Una anchura mínima evita ese detalle molesto.
// src/components/CodeBlockWithCopy.tsx
import { CopyButton } from "./CopyButton";
type CodeBlockWithCopyProps = {
code: string;
language?: string;
};
export function CodeBlockWithCopy({ code, language = "text" }: CodeBlockWithCopyProps) {
return (
<figure className="relative my-6 overflow-hidden rounded-md border border-slate-700 bg-slate-950">
<figcaption className="flex min-h-10 items-center justify-between border-b border-slate-800 px-3 text-xs text-slate-300">
<span>{language}</span>
<CopyButton
text={code}
label="Copy code"
copiedLabel="Copied"
className="min-w-24 rounded bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 disabled:opacity-60"
/>
</figcaption>
<pre tabIndex={0} className="overflow-x-auto p-4 text-sm leading-6">
<code>{code}</code>
</pre>
</figure>
);
}
El mismo botón puede copiar comandos CLI, enlaces de invitación, cupones, IDs de soporte o prompts generados. Pídele a Claude Code un componente reutilizable y luego ejemplos de uso, no una solución rígida para un solo bloque.
Pegar es entrada sensible
En formularios, prioriza el evento normal onPaste. Usa navigator.clipboard.readText() solo detrás de un botón explícito.
// src/components/PasteImportBox.tsx
import { useState } from "react";
export function normalizePastedText(input: string): string {
return input
.replace(/\r\n?/g, "\n")
.replace(/\u0000/g, "")
.slice(0, 10_000);
}
export function PasteImportBox() {
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
async function pasteFromClipboard() {
if (!navigator.clipboard?.readText || !window.isSecureContext) {
setMessage("Use your browser paste shortcut instead.");
return;
}
try {
const text = await navigator.clipboard.readText();
setValue(normalizePastedText(text));
setMessage("Pasted from clipboard.");
} catch {
setMessage("Paste was blocked. Use Ctrl+V or Cmd+V in the text area.");
}
}
return (
<section aria-labelledby="paste-import-title">
<h2 id="paste-import-title">Import prompt</h2>
<button type="button" onClick={pasteFromClipboard}>
Paste from clipboard
</button>
<textarea
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onPaste={(event) => {
const text = event.clipboardData.getData("text/plain");
if (!text) return;
event.preventDefault();
setValue(normalizePastedText(text));
setMessage("Pasted text was normalized.");
}}
aria-describedby="paste-import-help"
/>
<p id="paste-import-help" role="status" aria-live="polite">
{message}
</p>
</section>
);
}
No confíes en lo pegado. Limita longitud, normaliza saltos de línea, elimina caracteres de control y valida el formato esperado. Si aceptas HTML, pásalo por un sanitizador antes de renderizarlo y no lo envíes directamente a dangerouslySetInnerHTML.
Casos de uso y fallos comunes
| Caso | Punto clave | Fallo típico |
|---|---|---|
| Copiar código en docs | Copiar solo el string del código y anunciar éxito | El botón cambia de tamaño y mueve el layout |
| Copiar IDs de pedidos | Copiar solo el ID, no toda la fila | Se copian nombres o direcciones de clientes |
| Pegar logs en soporte | Normalizar, limitar tamaño y buscar secretos | Tokens o cookies quedan guardados sin límite |
| Compartir invitaciones | Copiar URL con vencimiento y mostrar la fecha | Enlaces caducados generan tickets de soporte |
También aplica a ClaudeCodeLab. Los comandos de un PDF gratuito, los pasos de un taller o una plantilla de diagnóstico para consultoría convierten mejor si se copian con precisión. Pero claves de licencia, correos de compradores y datos privados no deberían estar dentro de un único botón cómodo de copiar.
HTTP, iframes y Safari móvil
La Async Clipboard moderna requiere secure context. HTTPS y localhost suelen funcionar; una vista previa http://192.168.x.x puede no exponer navigator.clipboard. Prueba fallback y, si falla, muestra instrucciones de copia manual.
En iframes, los navegadores Chromium pueden requerir Permissions Policy o el atributo allow.
<iframe
src="https://docs.example.com/embed"
allow="clipboard-read; clipboard-write"
title="Documentation preview"
></iframe>
En Safari y WebKit de iOS, mantén la llamada al portapapeles muy cerca del click o tap. Evita esperar un fetch, temporizadores, animaciones o cambios de ruta antes de writeText. Prepara el valor antes del click, copia dentro del handler y luego actualiza la UI. Para flujos críticos, prueba en dispositivos iOS reales, no solo en Chrome de escritorio.
Tests Playwright para el portapapeles
Concede permisos al mismo origin que visitas. Playwright advierte que el soporte de permisos cambia entre navegadores y versiones, así que no fuerces el mismo nivel de prueba en todos. Chromium puede validar el contenido real; WebKit puede validar el feedback visible.
// tests/clipboard.spec.ts
import { expect, test } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
test.describe("clipboard UX", () => {
test.beforeEach(async ({ context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: baseURL,
});
});
test("copies a code block", async ({ page }) => {
await page.goto(`${baseURL}/docs/install`);
await page.getByRole("button", { name: /copy code/i }).first().click();
await expect(page.getByRole("status")).toContainText(/copied/i);
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toContain("npm");
});
test("normalizes pasted text", async ({ page }) => {
await page.goto(`${baseURL}/support/import`);
await page.evaluate(() => navigator.clipboard.writeText("line1\r\nline2\u0000"));
await page.getByRole("button", { name: /paste from clipboard/i }).click();
await expect(page.getByRole("textbox")).toHaveValue("line1\nline2");
});
});
Si falla en CI, revisa primero el origin. http://localhost:4173 y http://127.0.0.1:4173 son distintos. Después revisa soporte de permisos en el proyecto de navegador y si las etiquetas traducidas rompen tus queries por rol.
Checklist de accesibilidad
- Usa un
buttonreal, no undivclicable. - Anuncia éxito y error con
role="status"yaria-live="polite". - Usa etiquetas específicas: “Copy code”, “Copy invite link”, “Copy order ID”.
- Mantén visible el foco de teclado.
- Define anchura mínima para evitar saltos de layout.
- Si falla, explica la siguiente acción.
- No comuniques éxito solo con color.
- Lee el portapapeles solo tras una acción explícita de pegar.
Review enfocada con Claude Code
Después de implementar, no pidas una revisión genérica. Usa un prompt de riesgo concreto.
Review only clipboard-related changes.
Check:
1. Clipboard read is never triggered on page load.
2. writeText is called from a user action.
3. HTTP or unsupported browser fallback is handled.
4. copied/error feedback is accessible.
5. pasted text is normalized and size-limited.
6. Playwright tests grant permissions for the correct origin.
Return findings with file and line references.
En ClaudeCodeLab usamos funciones pequeñas como esta en entrenamientos porque obligan a cerrar el ciclo completo: leer especificación, implementar, diseñar fallback, probar navegadores, revisar accesibilidad y documentar. Para plantillas y guías, revisa productos ClaudeCodeLab. Para adopción en equipo y revisión de workflows, revisa Claude Code training.
Resultado al probarlo
En la UI de copia de código de Masa, el primer problema fue visual: el botón se ensanchaba después de copiar y movía el bloque. El segundo apareció en una vista previa móvil servida por HTTP, donde la ruta async no existía. La versión estable extrajo la copia a una utilidad, añadió fallback con textarea, mostró ayuda de copia manual, fijó el ancho del botón y configuró Playwright con permisos para el origin exacto. Clipboard API es una función pequeña, pero solo queda lista para producción cuando permisos, privacidad, accesibilidad y tests se diseñan juntos.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.