Skip to content
Download ZIP

Dark code block palette · 8.2 KB ZIP File receipt View as Markdown View code

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.
cmp_copy_button_01 · version 1.0.0 · Dark code block palette
Using the PageSugar MCP server, fetch component cmp_copy_button_01 version 1.0.0 with variant "code", 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
Dark code block
Version
1.0.0
Digest
Full digest
sha256-93af6f1722fa18dd142bf794f120369dae86cd0f42d386f2d8a1b756217afdce
CopyButton.svelte Svelte · 13.5 KB Raw
<!--
	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, #fafafa);
		--_ink: var(--copy-button-ink, #fafafa);
		--_muted: var(--copy-button-muted, #a1a1aa);
		--_hairline: var(--copy-button-hairline, #ffffff2e);
		--_surface: var(--copy-button-surface, #09090b);
		--_success: var(--copy-button-success, #4ade80);
		--_error: var(--copy-button-error, #f87171);

		/* 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>

Pass 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.clipboard needs 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 showValue there 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 showValue suits single-line values: a multi-line value is still copied exactly by the button, but reads and copies by hand as one line.

Example

Svelte
<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. label becomes its accessible name, so name the object: "Copy board link", not "Copy".

What happens on a press#

  1. navigator.clipboard.writeText(value) runs inside the click, before anything else is awaited, so the browser still counts it as a user action. Pass writeText to replace it.
  2. Success: the copy mark crossfades to a check, copiedLabel takes the label's place inside the width the button already had, and the status region announces it. After resetAfter milliseconds (2000 by default) it returns to idle; another press restarts the timer.
  3. Failure, or no Clipboard API at all (plain HTTP, an iframe without permission): the field is focused and selected, errorLabel is 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 keep showValue for 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
NameTypeRequiredDefaultDescription
valuestringYesNoneThe exact text written to the clipboard. An empty string disables the button.
labelstringNo'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'.
copiedLabelstringNo'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.
errorLabelstringNo'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.
showValuebooleanNofalseRender 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.
valueLabelstringNo'Value to copy'The accessible name of the field and of the group around it, when showValue is set: 'Install command', 'API token'.
placeholderstringNoNoneShown in the field while value is empty.
resetAfternumberNo2000Milliseconds before a successful copy returns to idle. Another click restarts the timer; Infinity keeps the copied state until the next click.
iconOnlybooleanNofalseA square icon button. label becomes its aria-label and copiedLabel is announced, not shown.
onCopy(result: { ok: boolean }) => voidNoNoneCalled after every press with whether that write succeeded, including presses overtaken by a later one.
writeText(text: string) => Promise<void>NoNoneReplaces navigator.clipboard.writeText, for a desktop shell's clipboard or a test. A rejected promise takes the failure path.
classstringNoNoneExtra classes on the root, for width and placement, such as max-w-md or a margin.

Customization#

On this page

Change 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 valueLabel that 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 copiedLabel short and close in length ('Copy' / 'Copied', 'Copy code' / 'Code copied'); the button reserves the longer one's width. Write errorLabel as what to do next.
  • Width: with showValue the 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. Without showValue it is an inline button.
  • Colours: --copy-button-surface is the page the control sits on and the note's fill; the field fill is mixed 3% toward --copy-button-ink from it. --copy-button-ink is the value and the label, --copy-button-muted the copy mark and placeholder, --copy-button-hairline the outline and the divider.
  • Status: --copy-button-success colours the check and --copy-button-error the failure mark. Both are paired with words, so they can be retoned to neutrals for a monochrome page.
  • Focus: --copy-button-accent is 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: resetAfter sets how long Copied stays (2000 ms by default). Pass writeText to use a desktop shell's clipboard, and onCopy to log or count copies yourself.

Public CSS variables

VariableToken
--copy-button-accentaccent
--copy-button-inkink
--copy-button-mutedmuted
--copy-button-hairlinehairline
--copy-button-surfacesurface
--copy-button-successsuccess
--copy-button-errorerror

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 announces copiedLabel separately; it never relies on the icon swap alone.
  • With showValue the field is a readonly <input> named by valueLabel, 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 errorLabel is 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.