Copy button
Copies text to the clipboard, says so out loud, and says so when it could not.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-copy-button.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-copy-button.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-copy-button.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-copy-button.jsonThe five-line version (navigator.clipboard.writeText, a copied flag, a setTimeout) is the
one every project writes, and it has four holes: it throws on any page not served over https, a
screen reader never hears that anything happened, the timer keeps running after the component is
gone, and a failure looks exactly like a success.
https://app.example.com/invite/7f3a9c <script lang="ts">
import CopyButton from '#lib/components/coral/kit/copy-button/copy-button.svelte';
const invite = 'https://app.example.com/invite/7f3a9c';
</script>
<div class="flex items-center gap-2 rounded-lg border py-1 ps-3 pe-1">
<code class="text-sm">{invite}</code>
<CopyButton text={invite} label="Copy link" copiedLabel="Link copied" />
</div>What Coral adds
- A fallback where the Clipboard API is missing: plain
httpon a LAN address, an embedded webview: the selection-based copy is tried, and the reader's own selection and focus are put back afterwards. - Failure is a state.
failedis drawn, announced and reported toonerror, instead of a check mark for a copy that never happened. - It is heard. Success and failure go through a polite status region. Changing the button's own
aria-label, which is how most copy buttons do it, is not announced on the element that has focus. - Text resolved on click. Pass a function (a signed URL, or an editor's current contents) and it
runs when the button is pressed, with a
copyingstate while it resolves. - No leaked timers: a reset scheduled before unmount is cancelled with it.
With text
The children snippet receives the status, so the label can change with it. With visible text the
accessible name comes from the text, and label is not applied.
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
import CopyButton from '#lib/components/coral/kit/copy-button/copy-button.svelte';
</script>
<CopyButton text="9f2c1ab3d84e7c05" variant="outline" copiedLabel="Commit SHA copied">
{#snippet children({ status })}
{#if status === 'copied'}
<CheckIcon data-icon="inline-start" /> Copied
{:else}
<CopyIcon data-icon="inline-start" /> Copy commit SHA
{/if}
{/snippet}
</CopyButton>Text computed on click
Status: idle
<script lang="ts">
import CopyButton from '#lib/components/coral/kit/copy-button/copy-button.svelte';
import type { CopyStatus } from '#lib/components/coral/kit/copy-button/types.js';
let status = $state<CopyStatus>('idle');
// Stands in for a request that signs a short-lived download link.
async function signLink() {
await new Promise((resolve) => setTimeout(resolve, 800));
return `https://files.example.com/d/${crypto.randomUUID()}?expires=600`;
}
</script>
<div class="flex flex-col items-center gap-3">
<CopyButton text={signLink} bind:status variant="outline" size="default">
{#snippet children({ status })}
{status === 'copying'
? 'Generating…'
: status === 'copied'
? 'Link copied'
: 'Copy temporary link'}
{/snippet}
</CopyButton>
<p class="text-sm text-muted-foreground">Status: {status}</p>
</div>status is bindable, so something beside the button can react to it. A copy that is still resolving
ignores further clicks.
Import
<script lang="ts">
import CopyButton from '#lib/components/coral/kit/copy-button/copy-button.svelte';
</script>Props
Everything the shadcn button accepts stays available: variant, size, disabled, class, aria-*, ref. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | () => string | Promise<string> | - | What gets copied. Required. A function runs on click. |
timeout | number | 2000 | Milliseconds before copied or failed goes back to idle. |
status | 'idle' | 'copying' | 'copied' | 'failed' | 'idle' | Bindable. |
oncopy | (text: string) => void | - | The text reached the clipboard. |
onerror | (error: unknown) => void | - | It did not. |
onclick | (event: MouseEvent) => void | - | Runs first. preventDefault skips the copy. |
label | string | Copy to clipboard | Accessible name when there is no visible text. |
copiedLabel | string | Copied | Announced on success. |
failedLabel | string | Copy failed | Announced on failure. |
children | Snippet<[{ status }]> | - | Replaces the icon. |
Accessibility
A native <button type="button">, so it never submits a surrounding form. The result is announced
through role="status", placed outside the button so it is not also read as part of its name. aria-busy is set while a function source is resolving.
clipboard.ts
writeText and resolveText are exported on their own, for copying from somewhere that is not a
button: a keyboard shortcut, a context menu item.
import { writeText } from '#lib/components/coral/kit/copy-button/clipboard.js';
await writeText(deployment.url); // rejects when the text did not reach the clipboard