Clipboard API com Claude Code: botões de copiar, permissões, fallback e testes
Implemente Clipboard API com Claude Code: copiar, colar, permissões, fallback, React, privacidade e Playwright.
Clipboard API parece simples: um botão chama navigator.clipboard.writeText() e o texto é copiado. Em produção, a história muda. O mesmo botão pode falhar em uma URL HTTP de preview, dentro de um iframe, depois de um fetch demorado, em navegadores com permissões diferentes ou em um fluxo de Safari móvel que espera uma interação recente do usuário.
Este guia mostra como usar Claude Code para construir uma experiência de copiar e colar pronta para React e TypeScript. Clipboard API é a API do navegador para ler ou escrever na área de transferência do sistema operacional. Async Clipboard é a versão baseada em Promises. Secure context é um ambiente que o navegador considera confiável, como HTTPS ou localhost. Fallback é o caminho alternativo quando a API moderna não está disponível.
Para aprofundar, leia também acessibilidade com Claude Code, testes Playwright com Claude Code e validação de formulários com Claude Code. Referências oficiais: MDN Clipboard API, MDN writeText, W3C Clipboard API and events, Playwright BrowserContext, WebKit Async Clipboard API e Claude Code docs.
Comece pelos critérios de aceite
Não peça apenas “crie um botão de copiar”. Entregue a Claude Code os casos de falha que importam.
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.
O fluxo esperado é este.
flowchart TD
A["Usuário clica em copiar"] --> B{"Async Clipboard disponível?"}
B -->|yes| C["writeText"]
B -->|no| D["textarea + execCommand fallback"]
C --> E{"Deu certo?"}
D --> E
E -->|yes| F["Anunciar copiado com aria-live"]
E -->|no| G["Mostrar orientação de cópia manual"]
H["Usuário cola explicitamente"] --> I["readText ou onPaste"]
I --> J["Normalizar, limitar, validar"]
A regra de privacidade é a parte mais importante: nunca leia a área de transferência ao carregar a página nem como ação invisível em segundo plano. Ela pode conter senhas, endereços, URLs internas, dados de clientes ou código privado. Leitura deve acontecer apenas em um botão visível de colar ou no evento normal onPaste.
Separe a lógica de cópia
Crie primeiro uma função sem dependência de React. Assim fallback, erro e teste ficam concentrados.
// 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") é uma API antiga e não deve ser o caminho principal. Ainda vale como último recurso em browsers antigos, WebViews restritos ou páginas HTTP de preview. Mesmo assim pode falhar se não estiver dentro de uma ação do usuário.
Hook React e botão acessível
O hook gerencia os estados de copiando, copiado e falha. O componente anuncia o resultado com role="status" e 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>
);
}
Um toast visual não basta. Usuários com leitor de tela também precisam saber o resultado. A região de status também vira um alvo estável para o Playwright.
Copiar blocos de código sem deslocamento
O caso mais comum é o bloco de código em documentação ou artigo. Na primeira versão do ClaudeCodeLab, Masa viu o botão ficar mais largo ao trocar “Copy” por “Copied”, deslocando a área de código. Uma largura mínima resolve.
// 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>
);
}
O mesmo botão serve para comandos CLI, links de convite, cupons, IDs de suporte ou prompts gerados. Peça a Claude Code um componente reutilizável e exemplos de uso, não uma implementação presa a um único bloco.
Colagem é entrada sensível
Em formulários, prefira o onPaste normal do navegador. Use navigator.clipboard.readText() apenas atrás de um botão 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>
);
}
Não confie no conteúdo colado. Limite tamanho, normalize quebras de linha, remova caracteres de controle e valide o formato. Se aceitar HTML, sanitize antes de renderizar e nunca envie o HTML do clipboard direto para dangerouslySetInnerHTML.
Casos de uso e falhas comuns
| Caso de uso | Detalhe de implementação | Falha comum |
|---|---|---|
| Copiar código em docs | Copiar só a string do código e anunciar sucesso | Texto do botão causa deslocamento |
| Copiar ID de pedido no admin | Copiar só o ID, não a linha inteira | Nome ou endereço do cliente vai junto |
| Colar logs no suporte | Normalizar, limitar e procurar secrets | Tokens ou cookies são salvos sem limite |
| Compartilhar convite | Copiar URL com validade e mostrar expiração | Links expirados geram chamados |
Isso também vale para produtos ClaudeCodeLab. Comandos de PDFs gratuitos, passos de workshop e prompts de diagnóstico para consultoria funcionam melhor quando o leitor copia exatamente o necessário. Chaves de licença, e-mails de compradores e dados privados não devem virar um botão único de copiar.
HTTP, iframe e Safari móvel
Async Clipboard moderna exige secure context. HTTPS e localhost costumam funcionar; http://192.168.x.x pode nem expor navigator.clipboard. Tente fallback e, se falhar, mostre instruções de cópia manual.
Dentro de iframes, browsers Chromium podem exigir Permissions Policy ou o atributo allow.
<iframe
src="https://docs.example.com/embed"
allow="clipboard-read; clipboard-write"
title="Documentation preview"
></iframe>
No Safari e no WebKit do iOS, mantenha a chamada de clipboard muito próxima do clique ou toque. Evite esperar fetch, timer, animação ou navegação antes de writeText. Prepare o valor antes, copie imediatamente no handler e só depois atualize a UI. Fluxos críticos devem ser testados em iPhone real.
Testes Playwright para clipboard
Conceda permissões ao mesmo origin que a página visita. A documentação do Playwright avisa que suporte a permissões varia por browser e versão. Na prática, Chromium pode verificar o conteúdo real do clipboard; WebKit pode verificar o feedback visual.
// 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");
});
});
Se falhar no CI, confira primeiro o origin. http://localhost:4173 e http://127.0.0.1:4173 são diferentes. Depois confira suporte de permissões e se rótulos traduzidos mudaram as queries por role.
Checklist de acessibilidade
- Use um
buttonreal, não umdivclicável. - Anuncie sucesso e erro com
role="status"earia-live="polite". - Use rótulos específicos: “Copy code”, “Copy invite link”, “Copy order ID”.
- Mantenha foco de teclado visível.
- Defina largura mínima para evitar deslocamento.
- Em falha, explique a próxima ação.
- Não indique sucesso apenas por cor.
- Leia o clipboard apenas depois de uma ação explícita de colar.
Review direcionada com Claude Code
Depois de implementar, peça uma revisão estreita.
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.
ClaudeCodeLab usa pequenas Web APIs como esta em treinamentos porque elas exigem o ciclo completo: ler especificação, implementar, desenhar fallback, testar browsers, revisar acessibilidade e documentar. Para templates e guias práticos, veja produtos ClaudeCodeLab. Para adoção em equipe e revisão de workflow, veja treinamento Claude Code.
Resultado do teste prático
Na UI de copiar código de Masa, o primeiro problema foi visual: depois da cópia, o botão ficava mais largo e empurrava o bloco. O segundo apareceu em um preview móvel servido por HTTP, onde a rota async não existia. A versão estável extraiu a cópia para uma utility, adicionou fallback com textarea e orientação de cópia manual, fixou a largura do botão e configurou Playwright com permissões para o origin exato. Clipboard API é pequena, mas só fica pronta para produção quando permissões, privacidade, acessibilidade e testes são desenhados juntos.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.