CopyButton
Clipboard button with copied-state feedback.
Live demo
01Default β icon + text
Variants
Sizes
In context β API key row
tfe_demo_4f8ab4d2e9c3401a9fd2b1c8e7d5a3f0 Click the icon to copy the API key.
With onCopy callback
Custom labels & faster reset
Implementation
02<script lang="ts">
import CopyButton from '$lib/components/CopyButton.svelte';
</script>
β
<CopyButton value="hello world" onCopy={(v) => console.log('copied', v)} />CopyButton wraps navigator.clipboard.writeText, then flips a copied state for copiedDuration ms (default 2000). The success message lives in an aria-live region so screen readers announce it without moving focus. Variants control whether you see the icon, the label, or both β useful when packing a copy action into tight inline contexts like API key rows.
Logic explainer
03What Does It Do? (Plain English)
A focused button that copies a string to the clipboard via navigator.clipboard.writeText, then flips its label and icon to a brief confirmation ("Copied!") for a configurable duration before reverting. Useful next to code blocks, share URLs, API keys, invite codes β anywhere users need to grab a value without selecting and right-clicking.
Think of it as a one-purpose magic wand: the value is already chosen, the user just needs to commit it to the clipboard. The visual flip from "Copy" to "Copied!" is the whole UX loop, no toast required.
How It Works (Pseudo-Code)
state:
copied = boolean (true while in success state)
resetTimer = setTimeout handle for reverting copied = false
events:
on click:
try:
await navigator.clipboard.writeText(value)
copied = true
fire onCopy(value)
clearTimeout(resetTimer)
resetTimer = setTimeout(() => copied = false, copiedDuration)
catch:
// Clipboard API unavailable or permission denied
// Stay in idle state; consumers can detect via onCopy not firing
derive displayLabel:
copied ? copiedLabel : label
render:
<button onclick={handleClick}>
if variant !== 'text': icon (clipboard | checkmark when copied)
if variant !== 'icon': label or copiedLabel
<span aria-live="polite" sr-only>copiedLabel or ''</span>
</button>The whole logic is one click handler and one timeout. The aria-live region is the screen-reader equivalent of the visual flip β when copied becomes true, the SR-only span announces "Copied!" through the live region without disrupting any other focus.
The Core Concept: Async Clipboard With Graceful Failure
navigator.clipboard.writeText returns a Promise that rejects in three real-world cases:
- Insecure context. The Clipboard API requires HTTPS (or
localhost) β calling it fromhttp://example.comrejects. - Permission denied. Browsers may prompt for clipboard permission on first use; a denial rejects.
- API not available. Older browsers (or hardened embeds like cross-origin iframes with
allow="clipboard-write"missing) don't exposenavigator.clipboardat all βwriteTextis undefined.
The try/catch is therefore essential, not optional. The strategy is silent failure:
try {
await navigator.clipboard.writeText(value);
// success path: flip to copied state
} catch {
// do nothing β copied stays false, label stays "Copy"
}The button doesn't show a "couldn't copy" error. The reasoning: most consumers don't have a UX for a copy failure (no error toast in scope, no fallback flow), so showing an error would just confuse the user. Instead, the visual flip simply doesn't happen. Consumers who care about success can rely on onCopy firing β it only fires on the success path, so (value) => analytics.track('copy', value) only tracks real copies.
For applications that need a fallback (e.g. very old Safari), wrap CopyButton's onCopy and pair it with a <textarea>-and-document.execCommand('copy') fallback. That dance isn't built in because it doubles the component's surface area for a vanishingly small audience.
The Reset Timer & Click-Spam Resilience
If the user clicks the button twice within copiedDuration, the second click would reset copied = true and start a new timeout. The previous timeout is still scheduled to fire and would set copied = false after the second click's timeout β clobbering the success state mid-flight.
The fix is to cancel the previous timer before scheduling a new one:
copied = true;
clearTimeout(resetTimer); // kill the in-flight reset
resetTimer = setTimeout(() => copied = false, copiedDuration);So spamming the button keeps the success state visible for copiedDuration after the last click β never longer, never shorter. The resetTimer is the only side-effect bookkeeping the component does; everything else is pure derived state.
Accessibility: aria-live Without Disrupting Focus
When the button flips to "Copied!", the visual change is obvious to sighted users. For screen readers, the label change inside the button isn't reliably announced β many AT implementations don't fire change events for in-place text mutations.
The fix is a separate aria-live="polite" region:
<span class="sr-only" role="status" aria-live="polite">
{copied ? copiedLabel : ''}
</span>When copied becomes true, the span's text changes from '' to copiedLabel. AT detects the live region's content change and announces it. Because aria-live="polite" waits for a quiet moment, the announcement doesn't interrupt anything the user was already reading.
The visual icon also flips β clipboard glyph β checkmark β so colour is not the only success signal. WCAG 1.4.1 satisfied.
State Flow Diagram
ββββββββββββββββββββββββββ
β IDLE β
β copied = false β
β shows label + π icon β
ββββββββββββββ¬βββββββββββββ
β
click
β
βΌ
ββββββββββββββββββββββββββ
β COPYING (async) β
β await writeText(value)β
ββββββββββ¬βββββββ¬ββββββββββ
β β
success β β failure (silent)
β β
βΌ βΌ
ββββββββββββββ back to IDLE
β COPIED β
β copied=trueβ
β clearTimer β
β setTimer( β
β duration) β
β shows β
β copiedLabelβ
β + β icon β
β aria-live β
β announces β
β onCopy() β
β fires β
βββββββ¬βββββββ
β
click again (spam)β timer fires after
β copiedDuration ms
β β
βΌ βΌ
clearTimer copied = false
setTimer back to IDLE
(extends
COPIED
duration)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
required | The text to copy. |
label |
string |
'Copy' |
Idle button text. |
copiedLabel |
string |
'Copied!' |
Success label after a successful copy. |
variant |
'text' | 'icon' | 'both' |
'both' |
Visual treatment: label only, icon only, or both. |
size |
'sm' | 'md' | 'lg' |
'md' |
Padding + font scale. |
copiedDuration |
number |
2000 |
Milliseconds to keep the success state visible. |
ariaLabel |
string |
label |
Override the button's aria-label. Defaults to the current display label. |
onCopy |
(value: string) => void |
β | Fires only after a successful copy. |
class |
string |
'' |
Extra classes on the button. |
Edge Cases
| Situation | Behaviour |
|---|---|
Page served over http:// (insecure context) |
navigator.clipboard.writeText rejects; the catch block fires; button stays in idle state. onCopy does not fire. |
| User has denied clipboard permission | Same as insecure context β silent failure. Consider showing a fallback "select and copy manually" path in your UI if this is likely. |
| User spam-clicks the button | Each click cancels the in-flight reset timer and schedules a new one. The success state stays visible for copiedDuration after the last click. |
value changes while in copied state |
The success label still says copiedLabel until the timer fires β there's no race. The next click writes the new value. |
variant="icon" and no ariaLabel |
aria-label falls back to label (default 'Copy'). AT users hear the button name even though no visible text is rendered. |
User has prefers-reduced-motion: reduce |
The colour transition is removed; the icon swap and label swap are instant. |
| Clipboard API undefined (very old browser) | navigator.clipboard?.writeText(value) would throw; the try/catch swallows it. Same silent-failure path. |
| Page navigates away mid-copy (Promise still pending) | The resetTimer becomes orphaned; the component unmount cleans nothing up explicitly but the timer fires harmlessly into a destroyed component (no-op). |
Dependencies
- Svelte 5.x β
$state,$derived,$props. The whole component is one async handler. - Browser Clipboard API β
navigator.clipboard.writeText. Requires a secure context (HTTPS or localhost). No fallback for old browsers β users on insecure or legacy contexts get silent failure. - Zero other external dependencies. Native button, scoped CSS, inline icon SVGs.
File Structure
src/lib/components/CopyButton.svelte # implementation
src/lib/components/CopyButton.md # this file (rendered inside ComponentPageShell)
src/lib/components/CopyButton.test.ts # vitest unit tests
src/routes/copybutton/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
value | string | β | Required. The string written to the clipboard. |
label | string | 'Copy' | Idle button label. |
copiedLabel | string | 'Copied!' | Label shown briefly after a successful copy. |
variant | 'text' | 'icon' | 'both' | 'both' | What to render in the button. |
size | 'sm' | 'md' | 'lg' | 'md' | Padding and icon size. |
copiedDuration | number | 2000 | How long the copied state holds (ms). |
ariaLabel | string | β | Required when variant is icon-only. |
onCopy | (value) => void | β | Fires after a successful copy. |