Action Feedback · 01.1
Copy Button
Copy to tick, width locked, reverts after 2s.
Install
The component is copied into your project, so the file is yours after that. It needs motion-sv.
Usage
<script lang="ts">
import CopyButton from '$lib/components/interior/copy-button.svelte';
let { token }: { token: string } = $props();
let copies = $state(0);
</script>
<div class="flex items-center gap-3">
<code class="mat-well rounded-[7px] px-2 py-1 font-mono text-[12px]">{token}</code>
<CopyButton
value={token}
label="Copy token"
copiedLabel="Copied"
onCopy={() => copies++}
onError={(reason) => console.error('clipboard refused', reason)}
/>
{#if copies > 0}
<span class="text-[12px] text-ink-3">copied {copies}×</span>
{/if}
</div>Source
<script lang="ts" module>
import type { ComponentProps } from 'svelte';
import { motion } from 'motion-sv';
export type Props = Omit<ComponentProps<typeof motion.button>, 'onerror'> & {
value: string;
label?: string;
copiedLabel?: string;
errorLabel?: string;
timeout?: number;
onCopy?: (value: string) => void;
onError?: (reason: unknown) => void;
};
</script>
<script lang="ts">
import { reducedMotion } from '$lib/reduced-motion.svelte';
import { cn } from '$lib/utils';
import { CopyToClipboardState, type CopyStatus } from './copy-button.state.svelte';
const EASE = [0.23, 1, 0.32, 1] as const;
const CELL = { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 } as const;
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
const DRAW = { duration: 0.26, ease: EASE } as const;
const INSTANT = { duration: 0 } as const;
let {
value,
label = 'Copy',
copiedLabel = 'Copied',
errorLabel = 'Failed',
timeout = 2000,
onCopy,
onError,
disabled = false,
class: className,
ref = $bindable(null),
...rest
}: Props = $props();
const clipboard = new CopyToClipboardState(() => ({ timeout, onCopy, onError }));
const status = $derived(clipboard.status);
const fade = $derived(reducedMotion.current ? INSTANT : CROSSFADE);
const draw = $derived(reducedMotion.current ? INSTANT : DRAW);
const labels = $derived<Array<[CopyStatus, string]>>([
['idle', label],
['copied', copiedLabel],
['error', errorLabel]
]);
</script>
<motion.button
type="button"
aria-label={label}
{...rest}
bind:ref
{disabled}
data-status={status}
onclick={() => clipboard.copy(value)}
whilePress={disabled || reducedMotion.current ? undefined : { y: 1 }}
transition={CELL}
class={cn(
'mat-cap group inline-flex h-9 touch-manipulation items-center gap-2 rounded-[9px] px-3 text-[13px] font-medium text-ink-2 select-none hover:text-ink disabled:opacity-50',
className
)}
>
<span class="grid size-3.5 shrink-0" aria-hidden="true">
<motion.svg
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="col-start-1 row-start-1 size-3.5"
initial={false}
animate={{ opacity: status === 'idle' ? 1 : 0, scale: status === 'idle' ? 1 : 0.92 }}
transition={fade}
>
<path
d="M9.6 5.1V3.7A1.7 1.7 0 0 0 7.9 2H3.7A1.7 1.7 0 0 0 2 3.7v4.2a1.7 1.7 0 0 0 1.7 1.7h1.4"
/>
<rect x="5.1" y="5.1" width="6.9" height="6.9" rx="1.7" />
</motion.svg>
<motion.svg
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="col-start-1 row-start-1 size-3.5"
initial={false}
animate={{ opacity: status === 'copied' ? 1 : 0, scale: status === 'copied' ? 1 : 0.92 }}
transition={fade}
>
<motion.path
d="M2.9 7.4 5.6 10.1 11.1 4"
initial={false}
animate={{ pathLength: status === 'copied' ? 1 : 0 }}
transition={draw}
/>
</motion.svg>
<motion.svg
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="col-start-1 row-start-1 size-3.5"
initial={false}
animate={{ opacity: status === 'error' ? 1 : 0, scale: status === 'error' ? 1 : 0.92 }}
transition={fade}
>
<path d="M3.6 3.6 10.4 10.4" />
<path d="M10.4 3.6 3.6 10.4" />
</motion.svg>
</span>
<span class="relative grid" aria-hidden="true">
{#each labels as [key, text] (key)}
<motion.span
class="col-start-1 row-start-1 whitespace-nowrap"
initial={false}
animate={key === status
? { opacity: 1, y: 0, filter: 'blur(0px)' }
: { opacity: 0, y: 3, filter: 'blur(3px)' }}
transition={fade}
>
{text}
</motion.span>
{/each}
</span>
<span role="status" aria-live="polite" class="sr-only">
{status === 'copied' ? copiedLabel : status === 'error' ? errorLabel : ''}
</span>
</motion.button>export type CopyStatus = 'idle' | 'copied' | 'error';
export type CopyToClipboardOptions = {
timeout?: number;
onCopy?: (value: string) => void;
onError?: (reason: unknown) => void;
};
export class CopyToClipboardState {
#read: () => CopyToClipboardOptions;
#status = $state<CopyStatus>('idle');
#timer: ReturnType<typeof setTimeout> | undefined;
constructor(options: CopyToClipboardOptions | (() => CopyToClipboardOptions) = {}) {
this.#read = typeof options === 'function' ? options : () => options;
$effect(() => () => clearTimeout(this.#timer));
}
get status() {
return this.#status;
}
get copied() {
return this.#status === 'copied';
}
reset = () => {
clearTimeout(this.#timer);
this.#status = 'idle';
};
copy = async (value: string) => {
if (!value) return;
let ok: boolean;
let reason: unknown = null;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
ok = true;
} else {
ok = writeFallback(value);
}
} catch (error) {
reason = error;
try {
ok = writeFallback(value);
} catch {
ok = false;
}
}
this.#status = ok ? 'copied' : 'error';
if (ok) this.#read().onCopy?.(value);
else this.#read().onError?.(reason);
clearTimeout(this.#timer);
this.#timer = setTimeout(() => (this.#status = 'idle'), this.#read().timeout ?? 2000);
};
}
function writeFallback(text: string): boolean {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(area);
const selection = document.getSelection();
const previous = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
area.select();
let ok: boolean;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
document.body.removeChild(area);
if (selection && previous) {
selection.removeAllRanges();
selection.addRange(previous);
}
return ok;
}Props
value string The text written to the clipboard. An empty value refuses the press rather than reporting a false success.
label "Copy"string Resting label, and the accessible name for every state.
copiedLabel "Copied"string Shown and announced after a successful write.
errorLabel "Failed"string Shown and announced when both the clipboard API and the fallback refuse.
timeout 2000number Milliseconds the result is held before reverting to idle. The timer restarts on each press.
onCopy (value: string) => void Fires once per successful write, with the value that landed.
onError (reason: unknown) => void Fires with the rejection when the write could not be made.
disabled falseboolean Refuses the press.
class string Merged last onto the button, so width and spacing are the caller’s.
CopyToClipboardState new (options?: CopyToClipboardOptions | (() => CopyToClipboardOptions)) The write and its outcome on their own, from './copy-button.state.svelte' - the rune-class form of upstream's useCopyToClipboard hook. `copy(value)` returns to idle after `timeout`, `status` is what you draw from, and the execCommand fallback for insecure origins comes with it. Construct it during component initialisation; it clears its own timer on teardown.