Skip to content
Chapter 16Lesson 2

Ship a Copy button that actually copies

How the browser's Clipboard API writes text to the system clipboard, and the secure-context and user-gesture rules every privileged browser capability shares.

You’re building an invoices table. Every row gets a small “Copy link” button so the user can grab the invoice URL and drop it into an email without leaving the page. They click, the URL lands on their clipboard, and a “Copied” tick flashes for a couple of seconds so they know it worked. This is one of the most ordinary buttons in a SaaS app, and you’ll write a dozen variants of it across any real product.

So you write the obvious thing, onClick={() => navigator.clipboard.writeText(url)}, and ship it. It works on your machine. Then the support tickets start: for some users the button does nothing, for others it works only when the page is quick and fails when it’s slow, and none of it reproduces locally. The call that looked like a one-liner depends on two conditions, and neither one shows up during local development. What are those two conditions, and why does the obvious version break in production but not in dev? The rest of the lesson answers that.

By the end you’ll have the version that survives production: one async call, placed where the browser will actually honor it, wrapped so it degrades gracefully when it can’t, and labelled so a screen reader announces the result. You’ll also know exactly which two conditions decide whether the text ever reaches the clipboard, so the next time you write this button, you write the correct version the first time.

The entire write API is one method. navigator.clipboard.writeText(text) takes a plain-text string and writes it to the system clipboard, the same clipboard the user pastes from anywhere on their machine. It’s asynchronous: it returns a Promise that resolves when the write succeeds and rejects when it fails.

await navigator.clipboard.writeText(invoiceUrl);

Two things are worth knowing before we go further. First, the write replaces the entire clipboard contents: there’s no append, no history, no merge. Whatever was on the clipboard is gone, and the new string is all that’s there. Second, writeText expects a string. Hand it anything else and it silently coerces with String(value), so passing an object lands the literal text [object Object] on the clipboard. Always pass a string.

This is the only copy API you write in 2026. If you search for “javascript copy to clipboard” you’ll surface a pile of pre-2022 answers built on document.execCommand('copy'), which creates a hidden element, selects its text, and calls an exec method synchronously. That API is deprecated, and the synchronous selection behavior it relied on is gone. Recognize it as the legacy pattern so you can avoid pasting it into a 2026 codebase.

The two gates that decide whether the write lands

Section titled “The two gates that decide whether the write lands”

Before writeText can put anything on the clipboard, two things must be true. Miss either and the call fails, but the two failures look genuinely different, and telling them apart is what separates a button that works from one that mysteriously doesn’t.

The clipboard write only works in a secure context : HTTPS, or localhost during development. This is the same gate you met with crypto.randomUUID and crypto.subtle in the previous lesson, so you handle it the same way, and the way to unblock it in local dev is the same mkcert setup.

One wrinkle is worth flagging, because it shapes how you handle the failure later. On a plain http:// page, navigator.clipboard isn’t a clipboard object that rejects your call. It’s simply undefined. So the failure isn’t a rejected Promise you can catch; it’s a TypeError the moment you reach for .writeText on undefined. We’ll come back to why that distinction matters when we handle failures. For now, serve over HTTPS or localhost and this gate is open.

This is the gate that catches people out, because it’s the one that’s always open in dev and frequently closed in production. The browser will only let you write to the clipboard as the direct result of a user action: a click, a key press, a pointer release. Without a gesture, there’s no write. This is deliberate, because a page that could silently rewrite your clipboard at any moment would be a security risk. The clipboard is a privileged surface you can only touch right after the user does something.

The part that’s easy to miss is that this permission isn’t permanent; it’s transient. The browser sets a “the user just interacted” flag on the gesture and then clears it about a second later. So it’s not enough that a click happened at some point: the write has to run while that flag is still live. Once too much time passes, or you hand control back to the browser across the wrong kind of async gap, the activation is spent and the call rejects with a NotAllowedError.

Transient user activation is the idea this lesson turns on, and the window it opens is invisible in code, so the diagram below makes it concrete. Scrub through three call sites and watch where each one lands relative to the activation window the click opens.

time →
activation live (~1s)
activation expires
click user gesture
writeText() from click handler
resolved

Called straight from the click handler: the write runs while the activation is still live, so it resolves.

time →
activation live (~1s)
activation expires
click user gesture
await fetch(…)
writeText() after await fetch()
NotAllowedError

The handler awaited a network round-trip first. By the time the write runs, the activation window has closed, so it rejects with NotAllowedError.

time → no gesture → no activation ever opens
writeText() setTimeout / useEffect
NotAllowedError

Fired from a timeout or an effect on mount: no gesture ever set the flag, so there’s no activation to ride, and it rejects with NotAllowedError.

The three call sites add up to one rule: the write goes inside the gesture handler, and it runs before you hand control back to the browser for anything slow. The first marker lands because the call is the first thing the click does. The second misses because a network round-trip outlived the activation: the fetch resolved a few hundred milliseconds later, with the flag already cleared. The third never had a chance, because a setTimeout and a mount effect both run with no gesture behind them at all.

This is why the call sits at the top of the click handler in the next section, not buried after some await that talks to the network, and never in a useEffect. If you genuinely need data from the server before you copy, fetch it before the user clicks and have the string ready, so the click handler does nothing but write.

Here’s a quick check before we build the button. Given a button whose click handler can do several things, which of these clipboard writes actually succeed?

A user clicks a Copy button. Which placements of navigator.clipboard.writeText(value) land on the clipboard? Select all that apply.

The handler’s opening line, run the instant the click fires.
Right after a value = formatUrl(id) that just slices a string together — no await between the click and the write.
After const value = await fetch('/api/invoice').then((r) => r.text()), using whatever the server sent back.
Two seconds on, inside the setTimeout(() => …, 2000) the click kicked off.
In the useEffect that fires once when the button first mounts.

Now assemble the real thing. The button has to run in the browser, since it has an interactive click handler and local feedback state, so its file is a Client Component : the 'use client' directive sits as the literal first line. You’ll meet that directive properly later in the course, when server and client components get their own chapter. For now, treat it as the marker that says “this code runs in the browser.”

That client boundary is a design decision, not a formality. You make the button itself the client component, a tiny island, and pass it the URL as a plain string prop from a Server Component parent that stays on the server. The mistake is turning the whole page into a client component just to host one Copy button: you’d ship far more JavaScript and lose the server rendering for everything around it. Keep the client component to the smallest interactive leaf, and feed it its data as props.

Here’s the shape, with each step calling out one decision.

'use client';
type CopyButtonProps = {
value: string;
label: string;
};
export const CopyButton = ({ value, label }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
timer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// the write rejected — degrade instead of lying (next section)
}
};
return (
<button type="button" onClick={copy} aria-label={label}>
<span aria-hidden>{copied ? 'Copied' : 'Copy'}</span>
<span role="status" className="sr-only">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
);
};

The client-island boundary. The 'use client' directive ships this one file to the browser. value is the string to copy and label is the accessible name, both handed down as props from a Server Component parent that stays on the server.

'use client';
type CopyButtonProps = {
value: string;
label: string;
};
export const CopyButton = ({ value, label }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
timer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// the write rejected — degrade instead of lying (next section)
}
};
return (
<button type="button" onClick={copy} aria-label={label}>
<span aria-hidden>{copied ? 'Copied' : 'Copy'}</span>
<span role="status" className="sr-only">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
);
};

The write is the first thing the handler does, reached synchronously the instant the click fires. This is the activation gate from the last section, in code. There’s no await fetch before it, no effect, and no timeout standing between the gesture and the write.

'use client';
type CopyButtonProps = {
value: string;
label: string;
};
export const CopyButton = ({ value, label }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
timer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// the write rejected — degrade instead of lying (next section)
}
};
return (
<button type="button" onClick={copy} aria-label={label}>
<span aria-hidden>{copied ? 'Copied' : 'Copy'}</span>
<span role="status" className="sr-only">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
);
};

The call rejects in real browsers, whether from expired activation or denied permission, so the catch is not optional. What goes inside it is the whole next section. For now, know that swallowing the error silently is the bug, not the fix.

'use client';
type CopyButtonProps = {
value: string;
label: string;
};
export const CopyButton = ({ value, label }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
timer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// the write rejected — degrade instead of lying (next section)
}
};
return (
<button type="button" onClick={copy} aria-label={label}>
<span aria-hidden>{copied ? 'Copied' : 'Copy'}</span>
<span role="status" className="sr-only">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
);
};

copied drives the label swap. A setTimeout flips it back after two seconds, and its handle lives in a ref so the cleanup can cancel it if the button unmounts mid-timer. The hook mechanics land in their own chapters later; the shape to take away here is feedback that cleans up after itself.

'use client';
type CopyButtonProps = {
value: string;
label: string;
};
export const CopyButton = ({ value, label }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
timer.current = setTimeout(() => setCopied(false), 2000);
} catch {
// the write rejected — degrade instead of lying (next section)
}
};
return (
<button type="button" onClick={copy} aria-label={label}>
<span aria-hidden>{copied ? 'Copied' : 'Copy'}</span>
<span role="status" className="sr-only">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
);
};

The visible content is the bare word “Copy” with no object, so aria-label="Copy invoice URL" gives a screen reader the full intent. The role="status" element is a live region: assistive tech announces its text when it changes, so a non-sighted user hears “Copied to clipboard” the moment the copy lands. Live regions get full treatment in a later chapter; the goal here is just to plant the correct attribute.

1 / 1

Two of the choices in that block are worth calling out. The visible text is wrapped in an aria-hidden span and the announcement lives in a separate role="status" span, so a screen reader hears one clean “Copied to clipboard” rather than the visible label and the status text talking over each other. And the <button> is a bare semantic button on purpose: in a real product you’d reach for your design system’s button component, but importing one here would pull focus from the clipboard API this lesson is about. The semantics that matter, type="button", the accessible label, and the live region, are all present; only the styling is left out.

When the write fails, degrade instead of lying

Section titled “When the write fails, degrade instead of lying”

A Copy button that catches the error, throws it away, and still flashes “Copied” is worse than one with no error handling at all. It tells the user the URL is on their clipboard when it isn’t, and they paste stale text into an email to a client. The catch branch isn’t cleanup you bolt on at the end; it’s a first-class part of the button’s behavior. Let’s make the failure path honest.

Two failure shapes are worth handling, and they are not the same kind of failure:

  • Activation expired or denied. The Promise rejects with a NotAllowedError, because the gesture window closed or the browser declined the request. This is a real runtime failure that can happen to a real user, so it belongs in the catch. The honest response is to tell them it didn’t copy and give them a way to copy it themselves.
  • Insecure context. On http://, recall that navigator.clipboard is undefined, so this never reaches your catch at all; it throws a TypeError the moment you read .writeText. This isn’t a user error, it’s a deployment misconfiguration, so the fix isn’t a friendly message to the user. The fix is making sure production is served over HTTPS so it can’t happen, and surfacing it loudly in development, with a console warning or a dev-only banner, if it ever does. Reaching for a catch (e) { if (e.name === '…') } branch won’t help here, because a TypeError thrown before the Promise exists never lands in that catch.

The recovery for the case that can reach a user, the rejected write, is a manual-copy affordance. When the automatic copy fails, render the value in a small read-only field with its text pre-selected, so the user can finish the job with Cmd/Ctrl+C themselves. The exact UI is yours, whether an inline field or a tiny popover, but the principle is fixed: never show success you didn’t achieve.

The two versions below are the whole argument of this section. One is the button most people would write; the other is the one you ship.

const copy = async () => {
navigator.clipboard.writeText(value);
setCopied(true);
};

Flashes “Copied” even when the write rejected. There’s no await and no catch, so the Promise rejects unhandled in the background while setCopied(true) runs anyway. The user sees a success tick for a clipboard that never changed, and pastes stale text.

Everything so far has been writing plain text, which is the part you’ll actually use day to day. The Clipboard API has two more corners worth recognizing so you know they exist, along with why you usually shouldn’t reach for them.

Reading the clipboard from JavaScript is possible: navigator.clipboard.readText() returns the clipboard’s text, and navigator.clipboard.read() returns richer content. But reads prompt the user for permission in Chromium browsers and behave differently in Safari, and they come up rarely in a SaaS app. When you want a paste, you almost always get it for free by wiring an <input> or <textarea> to the browser’s normal paste behavior, rather than reading the clipboard yourself. So don’t read the clipboard from JS unless the feature genuinely needs it, such as a rich-paste editor for a document or a screenshot dropped into a chat input. For an ordinary form, let the platform handle paste.

const pasted = await navigator.clipboard.readText();

Rich content is the other corner. writeText only does plain text. To put multiple representations on the clipboard at once you use navigator.clipboard.write with a ClipboardItem . You hand it a map from MIME type to content, and the paste target picks the format it understands: a rich editor takes the text/html, a terminal takes the text/plain.

await navigator.clipboard.write([
new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob }),
]);

The 2026 use for this is narrow: image and chart copy buttons, the “copy this generated diagram as an image” kind. If you ever build one, expect a Safari-specific wrinkle around async data and the gesture window: the value handed to a ClipboardItem may need to be a Promise that resolves inside the gesture, rather than something you awaited beforehand. That’s a detail to look up when you need it, not something to memorize now. Notice too that those values are Blobs, the binary container behind rich clipboard content. You’ll meet Blob and File properly in the next lesson; they’re the substrate under both rich clipboard content and file uploads.

You’ve built one small, reusable island. It doesn’t look like much, but it’s the same button you’ll drop next to share links, API keys, invite URLs, and generated code snippets across an entire product, written the same correct way every time.

A reusable copy island

CopyButton takes a string and a label and needs nothing else. Drop it next to any value a user might want on their clipboard, whether share URLs, secrets, or invite links, and it carries the gates, the degrade path, and the accessibility baseline with it.

The same two reflexes, everywhere

Secure context and a live user gesture aren’t clipboard trivia. They gate every privileged browser capability you’ll meet. Recognizing “this needs HTTPS and a real click” is the through-line of this whole chapter.

Next: the binary primitives

Rich clipboard content rode on Blobs. The next lesson installs Blob, File, and URL.createObjectURL, the same substrate behind file uploads and downloads.

The reference material worth a bookmark: the method itself, the security model that governs both reading and writing, and the gesture rule that decides whether a write lands.