Clipboard API with Claude Code: copy buttons, permissions, fallbacks, and tests
Implement Clipboard API with Claude Code: copy buttons, permissions, fallbacks, paste handling, React, and Playwright.
Clipboard API work looks tiny until it fails in production. A button that calls navigator.clipboard.writeText() may work on your laptop, then break on an HTTP preview URL, inside an iframe, after a delayed fetch, or on a mobile Safari flow where the browser expects a fresh user gesture.
This guide shows how to use Claude Code to build implementation-ready copy and paste UX in React and TypeScript. Clipboard API means the browser API that reads from or writes to the operating system clipboard. Async Clipboard means the Promise-based API. Secure context means HTTPS, localhost, or another origin the browser treats as trustworthy. Fallback means the older path we try when the modern API is unavailable.
For related ClaudeCodeLab articles, pair this with accessibility improvements in Claude Code, Playwright testing with Claude Code, and form validation with Claude Code. Keep the official references open: MDN Clipboard API, MDN writeText, W3C Clipboard API and events, Playwright BrowserContext, WebKit Async Clipboard API, and the Claude Code docs.
Start with acceptance criteria
Do not ask Claude Code for “a copy button” and hope the browser details are handled. Give it the failure modes up front.
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.
The working flow is simple, but every branch matters.
flowchart TD
A["User clicks copy"] --> B{"Async Clipboard available?"}
B -->|yes| C["writeText"]
B -->|no| D["textarea + execCommand fallback"]
C --> E{"Success?"}
D --> E
E -->|yes| F["Announce copied with aria-live"]
E -->|no| G["Show manual copy guidance"]
H["User explicitly pastes"] --> I["readText or onPaste"]
I --> J["Normalize, limit, validate"]
The privacy rule is the most important one: never read a user’s clipboard on page load or as a hidden background action. A clipboard can contain passwords, addresses, private URLs, customer data, or source code. Reads should happen only through a visible paste action or a normal onPaste event.
Build a reusable copy utility
Put the browser-specific work in a small utility before writing UI. That keeps fallbacks, errors, and tests in one place.
// 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") is deprecated, so it should not be your main path. It is still useful as a last-resort fallback for older browsers, restricted webviews, and HTTP previews. It can still fail, especially if the call is not triggered by a user action.
Add a React hook and accessible button
The hook below handles the temporary copied state and exposes one copy function. The component uses role="status" and aria-live so assistive technology can announce success or failure.
// 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>
);
}
The visible label is not enough. A user who cannot see the toast still needs confirmation that the action completed. The separate status region also gives Playwright a reliable assertion target.
Copy code blocks without layout shift
Documentation and blog code blocks are the common first use case. Masa’s first ClaudeCodeLab implementation had a subtle bug: when the label changed from “Copy” to “Copied”, the button became wider and nudged the code area. A fixed minimum width avoids that.
// 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>
);
}
The same button can copy a CLI command, invite link, coupon code, support case ID, or generated prompt. Ask Claude Code for a reusable component, then add usage examples instead of hard-coding the code-block case only.
Treat paste as sensitive input
Prefer the browser’s normal onPaste event in text inputs. Use navigator.clipboard.readText() only behind a visible paste button.
// 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>
);
}
Do not trust pasted content. Limit length, normalize line endings, remove control characters, and validate the expected format. If you accept HTML, sanitize it before rendering and never pass clipboard HTML directly into dangerouslySetInnerHTML.
Practical use cases and failure modes
| Use case | Implementation detail | Common failure |
|---|---|---|
| Copy code in docs | Copy only the code string and announce success | Button label changes cause layout shift |
| Copy order IDs in admin tools | Copy the ID only, not the whole table row | Customer names or addresses leak into clipboard |
| Paste logs into support tools | Normalize, cap size, and scan for secrets | Tokens or cookies are stored without limits |
| Share invite links | Copy a time-limited URL and show expiry nearby | Expired links create support tickets |
This matters for ClaudeCodeLab products too. Free PDFs, workshop setup commands, and consulting diagnostic prompts work better when readers can copy the exact command or template. But license keys, customer emails, and private purchase data should not be bundled into one convenient copy action.
HTTP, iframes, and mobile Safari caveats
Async Clipboard requires a secure context in modern browsers. HTTPS and localhost are fine; a plain http://192.168.x.x preview may not expose navigator.clipboard at all. Try the fallback, then give manual-copy guidance if both paths fail.
Inside iframes, Chromium-based browsers may require a Permissions Policy or an allow attribute.
<iframe
src="https://docs.example.com/embed"
allow="clipboard-read; clipboard-write"
title="Documentation preview"
></iframe>
For Safari and iOS WebKit, keep clipboard calls closely tied to the click or tap. Avoid waiting on fetch, timers, animations, or route changes before calling writeText. Prepare the value before the user clicks, copy immediately inside the handler, then run follow-up UI work. Do not overgeneralize from desktop Chrome; test critical flows on real iOS devices.
Playwright tests for clipboard UX
Grant permissions for the same origin you visit. Playwright notes that permission support differs by browser and browser version, so split tests when needed: Chromium can verify real clipboard content, while WebKit can still verify visible UI feedback.
// 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");
});
});
When this fails in CI, check the origin first. http://localhost:4173 and http://127.0.0.1:4173 are different origins. Then check whether the browser project supports the permissions you granted and whether translated button labels changed your role query.
Accessibility checklist
- Use a real
button, not a clickablediv. - Announce success and failure through
role="status"andaria-live="polite". - Make labels specific: “Copy code”, “Copy invite link”, “Copy order ID”.
- Keep keyboard focus visible.
- Give the button a stable minimum width to avoid layout shift.
- On failure, tell the user what to do next.
- Do not signal success by color alone.
- Read clipboard content only after an explicit paste action.
Ask Claude Code to review the risky parts
After implementation, run a narrow review prompt instead of a vague “check this.”
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 uses small Web API features like this in training because they force a complete engineering loop: specification reading, implementation, fallback design, browser testing, accessibility review, and content documentation. For templates and implementation guides, see ClaudeCodeLab products. For team enablement, see Claude Code training.
What happened when we tried it
In Masa’s code-block copy UI, the first issue was visual: the button widened after success and shifted the surrounding code. The second issue appeared on a phone preview served over HTTP, where the async clipboard path was unavailable. The stable version moved copying into a utility, added a textarea fallback and manual-copy guidance, fixed the button width, and made Playwright grant permissions for the exact origin under test. Clipboard looks like a small feature, but it becomes production-ready only when permissions, privacy, accessibility, and tests are designed together.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.