Copy button
Copies a supplied string and confirms it inline, as a standalone or icon-only button or joined to a read-only mono field showing the value, with a check that keeps the button's width and a manual-copy note on failure.
cmp_copy_button_01 Preview
Demonstration — no data is sent
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_copy_button_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-48cece942b42281ad52a9f29fe47c7154222f2b54ea93e42a15bdc51ed6380b6
<!--
Copies a supplied string and confirms it where the click happened. With showValue the value sits
in a read-only mono field joined to the button as one control; without it the button stands
alone, labelled or as an icon. The button's width is reserved for the longer of its two labels,
the result is announced through a status region that is always in the tree, and a failed copy
selects the value and floats a manual-copy note under the control without moving anything.
-->
<script lang="ts" module>
export type CopyResult = { ok: boolean };
export type CopyState = 'idle' | 'copied' | 'failed';
</script>
<script lang="ts">
interface Props {
/** The exact text written to the clipboard. An empty string disables the button. */
value: string;
/** The button's label and its name at rest. Icon-only buttons keep it as the name in every state, so name the object: "Copy install command". */
label?: string;
/** Shown in place of the label, and announced, after a successful copy. */
copiedLabel?: string;
/** Shown under the control and announced when copying fails. */
errorLabel?: string;
/** Show the value in a read-only field joined to the button. */
showValue?: boolean;
/** The field's accessible name, and the group's, when showValue is set. */
valueLabel?: string;
/** Shown in the field while value is empty. */
placeholder?: string;
/** Milliseconds before a successful copy returns to idle. Infinity keeps it until the next click. */
resetAfter?: number;
/** A square icon button; label becomes its aria-label. */
iconOnly?: boolean;
/** Called after every attempt, successful or not. */
onCopy?: (result: CopyResult) => void;
/** Replaces navigator.clipboard.writeText, for a native shell's clipboard or a test. */
writeText?: (text: string) => Promise<void>;
/** Extra classes on the root, for width and placement. */
class?: string;
}
let {
value,
label = 'Copy',
copiedLabel = 'Copied',
errorLabel = 'Copy failed. Select the text and copy it manually.',
showValue = false,
valueLabel = 'Value to copy',
placeholder,
resetAfter = 2000,
iconOnly = false,
onCopy,
writeText,
class: className
}: Props = $props();
const uid = $props.id();
let copyState = $state<CopyState>('idle');
let announcement = $state('');
let root = $state<HTMLElement>();
let field = $state<HTMLInputElement>();
let timer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
let flip = false;
let destroyed = false;
const empty = $derived(!value);
function clearTimer() {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
}
/*
* A polite region only speaks when its text changes, so a repeat alternates a trailing no-break
* space. The last result stays in the region after the visual reset, so a reader that delivers
* it late still has something to read.
*/
const NBSP = String.fromCharCode(160);
function announce(text: string) {
flip = !flip;
announcement = flip ? text : text + NBSP;
}
function reset() {
clearTimer();
copyState = 'idle';
}
/* Dismissing also invalidates a write still in flight, so its result cannot reopen the note. */
function dismiss() {
attempt++;
reset();
}
function succeed() {
clearTimer();
copyState = 'copied';
announce(copiedLabel);
if (Number.isFinite(resetAfter)) timer = setTimeout(reset, Math.max(0, resetAfter));
}
function fail() {
copyState = 'failed';
announce(errorLabel);
/*
* Hand the value to the keyboard, focused and selected for Ctrl+C, unless focus has already
* moved somewhere else on the page.
*/
const active = document.activeElement;
if (field && (!active || active === document.body || root?.contains(active))) {
field.focus();
field.select();
/* select() scrolls to the end; show the start, where a command says what it is. */
field.scrollLeft = 0;
}
}
function clipboardWrite(text: string): Promise<void> {
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
if (!clipboard?.writeText) return Promise.reject(new Error('Clipboard API unavailable'));
return clipboard.writeText(text);
}
async function copy() {
if (empty) return;
const current = ++attempt;
/* A retry closes the last result at once instead of leaving it up while the write runs. */
reset();
const ok = await (writeText ?? clipboardWrite)(value).then(
() => true,
() => false
);
if (destroyed) return;
/* Only the latest attempt drives the state; every attempt reports to onCopy. */
if (current === attempt) {
if (ok) succeed();
else fail();
}
onCopy?.({ ok });
}
/*
* The failure note stays until the next attempt, Escape, or focus or a click moving elsewhere.
* Escape is heard on the document: Safari does not focus a button on click, so focus may never
* have entered the control.
*/
$effect(() => {
const el = root;
if (!el || copyState !== 'failed') return;
const keydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismiss();
};
const focusout = (event: FocusEvent) => {
if (!el.contains(event.relatedTarget as Node | null)) dismiss();
};
const pointerdown = (event: PointerEvent) => {
if (!el.contains(event.target as Node | null)) dismiss();
};
document.addEventListener('keydown', keydown);
el.addEventListener('focusout', focusout);
document.addEventListener('pointerdown', pointerdown);
return () => {
document.removeEventListener('keydown', keydown);
el.removeEventListener('focusout', focusout);
document.removeEventListener('pointerdown', pointerdown);
};
});
$effect(() => () => {
destroyed = true;
clearTimer();
});
</script>
{#snippet face(compact: boolean)}
<!-- Both marks share one cell and crossfade, so the swap never moves the label. -->
<span class="grid size-4 shrink-0 place-items-center" aria-hidden="true">
<svg
viewBox="0 0 16 16"
fill="none"
class={[
'size-4 transition-[opacity,scale] duration-150 ease-(--_ease) [grid-area:1/1] motion-reduce:scale-100 motion-reduce:transition-opacity',
copyState === 'copied'
? 'scale-75 opacity-0'
: 'text-(--_muted) group-hover:text-(--_ink) group-disabled:text-(--_muted)'
]}
>
<rect
x="5.75"
y="5.75"
width="7.5"
height="7.5"
rx="1.5"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M10.25 3.5v-.25a1.5 1.5 0 0 0-1.5-1.5h-5a1.5 1.5 0 0 0-1.5 1.5v5a1.5 1.5 0 0 0 1.5 1.5h.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
<svg
viewBox="0 0 16 16"
fill="none"
class={[
'size-4 text-(--_success) transition-[opacity,scale] duration-150 ease-(--_ease) [grid-area:1/1] motion-reduce:scale-100 motion-reduce:transition-opacity',
copyState === 'copied' ? 'opacity-100' : 'scale-75 opacity-0'
]}
>
<path
d="M3.25 8.5 6.5 11.75l6.25-7.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
{#if !iconOnly}
<!--
Both labels share one cell, centred, so the button is as wide as the longer one in every state
and neither sits off to one side. The visible one is the button's name (label in name); the
other is hidden from the tree. In a narrow joined row the labels go visually hidden, still
naming the button, so the value keeps the width it needs to say what it is.
-->
<span class={['grid justify-items-center', compact && '@max-sm:sr-only']}>
<span
class={[
'transition-opacity duration-150 ease-(--_ease) [grid-area:1/1]',
copyState === 'copied' && 'opacity-0'
]}
aria-hidden={copyState === 'copied' ? 'true' : undefined}>{label}</span
>
<span
class={[
'transition-opacity duration-150 ease-(--_ease) [grid-area:1/1]',
copyState !== 'copied' && 'opacity-0'
]}
aria-hidden={copyState !== 'copied' ? 'true' : undefined}>{copiedLabel}</span
>
</span>
{/if}
{/snippet}
<div
bind:this={root}
class={[
'copy-button relative',
showValue ? '@container flex w-full min-w-0' : 'inline-flex align-middle',
className
]}
role={showValue ? 'group' : undefined}
aria-label={showValue ? valueLabel : undefined}
data-state={copyState}
>
{#if showValue}
<div
class="flex h-9 min-w-0 flex-1 rounded-lg bg-(--_fill) shadow-(--_edge) pointer-coarse:h-11"
>
<!--
The field keeps the row's direction, so its corners and focus ring round the outer end in
RTL too, while unicode-bidi: plaintext lets a left-to-right command read left to right.
-->
<input
bind:this={field}
id="{uid}-value"
type="text"
readonly
{value}
{placeholder}
spellcheck="false"
autocomplete="off"
aria-label={valueLabel}
class="min-w-0 flex-1 rounded-s-lg bg-transparent px-3 font-mono text-[13px] leading-5 text-ellipsis text-(--_ink) -outline-offset-2 outline-(--_accent) [font-variant-ligatures:none] [unicode-bidi:plaintext] placeholder:font-sans placeholder:text-(--_muted) focus-visible:outline-2"
onfocus={(event) => event.currentTarget.select()}
/>
<button
type="button"
class={[
'group flex h-full shrink-0 cursor-pointer items-center justify-center gap-2 rounded-e-lg border-s border-(--_hairline) text-sm leading-5 font-medium text-(--_ink) select-none',
'transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_fill-hover) active:bg-(--_fill-press) active:duration-[80ms]',
'-outline-offset-2 outline-(--_accent) focus-visible:outline-2',
'disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-50',
iconOnly ? 'w-9 pointer-coarse:w-11' : 'px-3 @max-sm:w-11 @max-sm:px-0'
]}
disabled={empty}
aria-label={iconOnly ? label : undefined}
aria-controls="{uid}-value"
onclick={copy}
>
{@render face(true)}
</button>
</div>
{:else}
<button
type="button"
class={[
'group flex h-9 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg bg-(--_fill) text-sm leading-5 font-medium text-(--_ink) shadow-(--_edge) select-none pointer-coarse:h-11',
'transition-[color,background-color,scale] duration-150 ease-(--_ease) hover:bg-(--_fill-hover-solid) active:scale-[.98] active:duration-[80ms] motion-reduce:transition-[color,background-color] motion-reduce:active:scale-100',
'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100',
iconOnly ? 'w-9 pointer-coarse:w-11' : 'px-3'
]}
disabled={empty}
aria-label={iconOnly ? label : undefined}
onclick={copy}
>
{@render face(false)}
</button>
{/if}
<!-- Always in the tree, so the first result is announced as reliably as the rest. -->
<span class="sr-only" role="status">{announcement}</span>
<!-- The status region already speaks this; the note is the sighted half of it. -->
<div
class="copy-button__note pointer-events-none absolute start-0 top-full z-10 mt-2 w-max max-w-[min(18rem,calc(100vw-2rem))] gap-2 rounded-lg bg-(--_surface) px-3 py-2 text-xs leading-5 text-(--_ink) shadow-(--_shadow-note)"
data-open={copyState === 'failed' ? '' : undefined}
aria-hidden="true"
>
<span class="flex h-lh shrink-0 items-center text-(--_error)">
<svg viewBox="0 0 16 16" fill="none" class="size-4">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
<path d="M8 4.75v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<circle cx="8" cy="11" r=".875" fill="currentColor" />
</svg>
</span>
<span class="text-balance">{errorLabel}</span>
</div>
</div>
<style>
/* Public tokens: set --copy-button-* on the control or any ancestor to retone it. */
.copy-button {
--_accent: var(--copy-button-accent, #18181b);
--_ink: var(--copy-button-ink, #18181b);
--_muted: var(--copy-button-muted, #52525b);
--_hairline: var(--copy-button-hairline, rgb(0 0 0 / 0.18));
--_surface: var(--copy-button-surface, #ffffff);
--_success: var(--copy-button-success, #15803d);
--_error: var(--copy-button-error, #b91c1c);
/* Formulas: fills mixed from the surface and ink, so they follow any retone. */
--_fill: color-mix(in oklab, var(--_surface) 97%, var(--_ink));
--_fill-hover: color-mix(in oklab, var(--_ink) 5%, transparent);
--_fill-press: color-mix(in oklab, var(--_ink) 9%, transparent);
--_fill-hover-solid: color-mix(in oklab, var(--_surface) 93%, var(--_ink));
--_edge: inset 0 0 0 1px var(--_hairline);
/* The popover elevation from DESIGN §3.5, ringed in the hairline so it reads on dark too. */
--_shadow-note:
0 0 0 1px var(--_hairline), 0 4px 6px -1px rgb(0 0 0 / 0.07),
0 10px 15px -3px rgb(0 0 0 / 0.05);
--_ease: cubic-bezier(0.2, 0, 0, 1);
}
/*
* The note's open and close run on different curves and durations, and it leaves layout entirely
* while closed (display: none), so a hidden note can never widen the page. Utilities cannot
* sequence a discrete display change with a fade. Reduced motion keeps the fade, drops the lift.
*/
.copy-button__note {
display: none;
opacity: 0;
translate: 0 -4px;
transition:
opacity 150ms cubic-bezier(0.4, 0, 1, 1),
translate 150ms cubic-bezier(0.4, 0, 1, 1),
display 150ms allow-discrete;
}
.copy-button__note[data-open] {
display: flex;
opacity: 1;
translate: 0 0;
transition:
opacity 200ms cubic-bezier(0.16, 1, 0.3, 1),
translate 200ms cubic-bezier(0.16, 1, 0.3, 1),
display 200ms allow-discrete;
}
@starting-style {
.copy-button__note[data-open] {
opacity: 0;
translate: 0 -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.copy-button__note,
.copy-button__note[data-open] {
translate: none;
}
}
</style>
Usage#
On this pagePass the exact text as value. Set showValue to show it in a read-only field joined to the button, or iconOnly for a square button whose label becomes its aria-label. It copies plain text only: no rich text, no images, and no document.execCommand fallback; when the Clipboard API is missing or refuses, it selects the value and asks the reader to copy it themselves.
- Suggested location
src/lib/components/copy-button-01- Required props
value
Limitations
navigator.clipboardneeds a secure context (HTTPS or localhost) and a click; on plain HTTP, or in an iframe without clipboard-write permission, every attempt takes the failure path.- Plain text only. Rich text, HTML and images are out of scope, and there is no
document.execCommand('copy') fallback. - The failure note is absolutely positioned under the control; an overflow-hidden ancestor can clip it. It closes on the next attempt, Escape anywhere on the page, or focus or a click moving outside the control.
- Without
showValuethere is no field to select on failure, so keep the value visible nearby when you use the button on its own. - The value field is a single line: long values end in an ellipsis at rest and scroll when focused. It does not wrap code.
- The field is a text input, which drops line breaks, so
showValuesuits single-line values: a multi-line value is still copied exactly by the button, but reads and copies by hand as one line.
Example
<script lang="ts">
import CopyButton from '$lib/components/copy-button-01/CopyButton.svelte';
</script>
<CopyButton
value="npm install --global @halcyon/cli"
showValue
valueLabel="Install command"
onCopy={({ ok }) => console.log(ok ? 'copied' : 'failed')}
/>
<CopyButton value="FIRSTLOAF" label="Copy code" copiedLabel="Code copied" />
<CopyButton value="https://example.com/share/q4" iconOnly label="Copy share link" />The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Copy button#
Three shapes, one component#
- Joined field (
showValue): the value in a read-only mono field and the button at its end, as one control under one outline. Use it for install commands, tokens and share links, where the reader should see exactly what they are copying. - Standalone (default): a labelled button, for when the value is already shown in your own markup, such as a coupon code set large on an offer card.
- Icon only (
iconOnly): a square button, alone or at the end of the field.labelbecomes its accessible name, so name the object: "Copy board link", not "Copy".
What happens on a press#
navigator.clipboard.writeText(value)runs inside the click, before anything else is awaited, so the browser still counts it as a user action. PasswriteTextto replace it.- Success: the copy mark crossfades to a check,
copiedLabeltakes the label's place inside the width the button already had, and the status region announces it. AfterresetAftermilliseconds (2000 by default) it returns to idle; another press restarts the timer. - Failure, or no Clipboard API at all (plain HTTP, an iframe without permission): the field is
focused and selected,
errorLabelis announced, and a short note floats under the control. The note closes on the next press, on Escape, or when focus or a click moves elsewhere. The field is a text input and drops line breaks, so keepshowValuefor single-line values.
onCopy({ ok }) runs after every press, so you can log or count copies yourself; the component
sends nothing anywhere.
Placing it#
With showValue the root fills its container's width. Cap it with class="max-w-md" or put it in
a column. The failure note is absolutely positioned below the control, so leave it room or
avoid an overflow: hidden parent.
Dark code blocks#
The code palette retones the control for a dark code block on a light page. To do the same
yourself, set the seven variables listed in the customization guide on any ancestor.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
value | string | Yes | None | The exact text written to the clipboard. An empty string disables the button. |
label | string | No | 'Copy' | The button's visible label and its accessible name at rest. In icon-only mode it is the aria-label in every state, so name the object: 'Copy install command'. |
copiedLabel | string | No | 'Copied' | Shown in place of the label, and announced, after a successful copy; while it shows, it is also the button's name. The button is as wide as the longer of the two. |
errorLabel | string | No | 'Copy failed. Select the text and copy it manually.' | The note shown under the control, and announced, when copying fails or the Clipboard API is missing. |
showValue | boolean | No | false | Render the value in a read-only mono field joined to the button. The root then fills its container's width, and below 24rem the labelled button folds to a 44 px icon square (the label still names it) so the value keeps its room. |
valueLabel | string | No | 'Value to copy' | The accessible name of the field and of the group around it, when showValue is set: 'Install command', 'API token'. |
placeholder | string | No | None | Shown in the field while value is empty. |
resetAfter | number | No | 2000 | Milliseconds before a successful copy returns to idle. Another click restarts the timer; Infinity keeps the copied state until the next click. |
iconOnly | boolean | No | false | A square icon button. label becomes its aria-label and copiedLabel is announced, not shown. |
onCopy | (result: { ok: boolean }) => void | No | None | Called after every press with whether that write succeeded, including presses overtaken by a later one. |
writeText | (text: string) => Promise<void> | No | None | Replaces navigator.clipboard.writeText, for a desktop shell's clipboard or a test. A rejected promise takes the failure path. |
class | string | No | None | Extra classes on the root, for width and placement, such as max-w-md or a margin. |
Customization#
On this pageChange the copy, labels and layout through props, and retone the field, the button, the note and the status marks through seven --copy-button-* CSS variables. The field fill and the hover tones are mixed from surface and ink, so they follow any retone.
- Content: pass the exact string as value; what is shown is what is copied. Give the field a
valueLabelthat names the thing ('Install command'), and an icon-only button a label that names the action and the thing ('Copy board link'). - Labels: keep label and
copiedLabelshort and close in length ('Copy' / 'Copied', 'Copy code' / 'Code copied'); the button reserves the longer one's width. WriteerrorLabelas what to do next. - Width: with
showValuethe control fills its container, so cap it with class (max-w-md) or put it in a column. It is a size container: below 24rem the labelled button folds to an icon square so a phone still sees most of the value. WithoutshowValueit is an inline button. - Colours:
--copy-button-surfaceis the page the control sits on and the note's fill; the field fill is mixed 3% toward--copy-button-inkfrom it.--copy-button-inkis the value and the label,--copy-button-mutedthe copy mark and placeholder,--copy-button-hairlinethe outline and the divider. - Status:
--copy-button-successcolours the check and--copy-button-errorthe failure mark. Both are paired with words, so they can be retoned to neutrals for a monochrome page. - Focus:
--copy-button-accentis the focus ring. It is the only accent this control paints, because a copy button is never the primary action. - Worked retone, a dark code block on a light docs page (the code palette):
--copy-button-surface:#09090b;--copy-button-ink:#fafafa;--copy-button-muted:#a1a1aa;--copy-button-hairline: #ffffff2e;--copy-button-accent:#fafafa;--copy-button-success:#4ade80;--copy-button-error:#f87171. - Behaviour:
resetAftersets how long Copied stays (2000 ms by default). PasswriteTextto use a desktop shell's clipboard, andonCopyto log or count copies yourself.
Public CSS variables
| Variable | Token |
|---|---|
--copy-button-accent | accent |
--copy-button-ink | ink |
--copy-button-muted | muted |
--copy-button-hairline | hairline |
--copy-button-surface | surface |
--copy-button-success | success |
--copy-button-error | error |
Accessibility#
On this page- A native <button type="button">, so Enter and Space copy with no extra handlers. A labelled button is named by the label it shows (label in name), so it reads 'Copied' while that is on screen; the icons are
aria-hidden. - The result is announced through a role="status" region that is always rendered, so the first copy is announced as reliably as the tenth. Repeated copies are announced each time.
- Icon-only mode keeps a constant
aria-label('Copy board link') and announcescopiedLabelseparately; it never relies on the icon swap alone. - With
showValuethe field is a readonly <input> named byvalueLabel, inside a group with the same name, so keyboard users can reach, select and copy the value themselves. Focus selects all of it. - On failure the field is focused and selected, ready for Ctrl+C or Cmd+C, and
errorLabelis announced; the visible note repeats it and is hidden from the tree to avoid a double reading. - An empty value disables the button with the native attribute, which removes it from the tab order; say nearby why there is nothing to copy (the placeholder can).
- The focus ring is two pixels in
--copy-button-accent: outside a standalone button, inside the field and the joined button so it follows the shared outline. Keep it at 3:1 against the surface. - Targets are 36 px tall at app density, growing to 44 px on coarse pointers.
- The success and error marks are always paired with words; colour is never the only signal.
Known limitations
- Screen readers do not announce the copied text itself; name what is being copied with label or
valueLabel.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.