Skip to content
Download ZIP

Neutral palette · 10.1 KB ZIP File receipt View as Markdown View code

Preview

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_copyable_value_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_copyable_value_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-e5968ce49b4005932dbf7be96940e00c0651ea5ccfb53a86d07abe0391de9557

This component needs all 2 files. Download the ZIP

CopyableValue.svelte Svelte · 16.2 KB Raw
<!--
	One value (an order number, an API key, a wallet address, a coupon code) with a copy action.
	Boxed, the value sits in a tinted read-only well with the Copy button attached inside the same
	hairline; inline, it runs in a row of text with a small icon button. The confirmation morphs in
	place and is announced politely, a failure says so and selects the text for a manual copy, and
	the value stays ordinary selectable text, so it can be copied by hand before any script runs.
-->
<script lang="ts" module>
	export type CopyableValueLayout = 'boxed' | 'inline';
	export type CopyableValueTruncate = 'middle' | 'end' | 'wrap';
	export type CopyableValueStatus = 'idle' | 'copied' | 'failed';
	export interface CopyableValueResult {
		ok: boolean;
	}
</script>

<script lang="ts">
	import { copyText } from './copy-text';

	interface Props {
		/** The exact text written to the clipboard. */
		value: string;
		/** Names the value, and the buttons: "Copy {label}", "Show {label}". */
		label: string;
		/** Shown instead of value when they differ, such as a shortened form. Copy still writes value. */
		display?: string;
		/** Monospace type for identifiers, keys and addresses. */
		mono?: boolean;
		/** Hide the value behind dots with a Show toggle. Visual only: the value is still in the page. */
		mask?: boolean;
		/** Whether a masked value is showing. Bindable. */
		revealed?: boolean;
		/** 'boxed' sets the value in a field with an attached Copy button; 'inline' runs it in text. */
		layout?: CopyableValueLayout;
		/** How a value too long for its width is shortened. 'middle' keeps the start and the last characters. */
		truncate?: CopyableValueTruncate;
		/** Characters kept at the end when truncate is 'middle'. */
		tailLength?: number;
		/** Keep the label for assistive technology only, when the context already names the value. */
		hideLabel?: boolean;
		/** Visible button text, and the start of its name. */
		copyLabel?: string;
		/** Confirmation after a successful copy. */
		copiedLabel?: string;
		/** Message after a failed copy. The value is selected so the person can copy it by hand. */
		failedLabel?: string;
		/** Shown in the field when value is empty, such as a tracking number not issued yet. */
		emptyLabel?: string;
		/** Start of the reveal toggle's name: "Show {label}". */
		showLabel?: string;
		/** Read in place of a masked value. */
		hiddenLabel?: string;
		/** Milliseconds before the confirmation returns to rest. 0 keeps it. Failures stay until the next try. */
		resetAfter?: number;
		/** Direction of the value itself. Identifiers read left to right on a right-to-left page too. */
		valueDir?: 'ltr' | 'rtl' | 'auto';
		/** Called after every copy attempt. */
		onCopy?: (result: CopyableValueResult) => void;
		/** Extra classes on the root, for placement. */
		class?: string;
	}

	let {
		value,
		label,
		display,
		mono = false,
		mask = false,
		revealed = $bindable(false),
		layout = 'boxed',
		truncate = 'middle',
		tailLength = 6,
		hideLabel = false,
		copyLabel = 'Copy',
		copiedLabel = 'Copied',
		failedLabel = 'Copy failed. Copy the text manually.',
		emptyLabel = 'Not available yet',
		showLabel = 'Show',
		hiddenLabel = 'Hidden',
		resetAfter = 2000,
		valueDir = 'ltr',
		onCopy,
		class: className
	}: Props = $props();

	const uid = $props.id();
	const labelId = `${uid}-label`;

	const boxed = $derived(layout === 'boxed');
	const hidden = $derived(mask && !revealed);
	const shown = $derived(display ?? value);
	/* Middle truncation splits the text in two; the ellipsis is drawn by CSS, so it is never copied. */
	const tail = $derived(Math.max(0, Math.min(tailLength, Math.floor(shown.length / 2))));
	/* A revealed secret is shown whole on one line that scrolls, so every character can be checked. */
	const scroll = $derived(mask && revealed);
	const split = $derived(!scroll && truncate === 'middle' && tail > 0 && shown.length > tail * 2);
	/* When the visible text is split or differs from value, assistive technology reads value whole. */
	const proxy = $derived(split || (display !== undefined && display !== value));

	let status = $state<CopyableValueStatus>('idle');
	let valueEl = $state<HTMLElement>();
	let timer: ReturnType<typeof setTimeout> | undefined;
	/* Each attempt takes a number; only the latest one still mounted may change the status. */
	let attempt = 0;
	let alive = true;

	$effect(() => () => {
		alive = false;
		attempt++;
		clearTimeout(timer);
	});

	/* A new value drops any confirmation or pending attempt that belonged to the old one. */
	$effect.pre(() => {
		void value;
		attempt++;
		clearTimeout(timer);
		status = 'idle';
	});

	async function copy() {
		if (!value) return;
		const current = ++attempt;
		const stale = () => !alive || current !== attempt;
		clearTimeout(timer);
		/* Back to rest first, so a second copy is announced again. */
		status = 'idle';
		const ok = await copyText(value, stale);
		if (stale()) return;
		status = ok ? 'copied' : 'failed';
		if (ok && resetAfter > 0) timer = setTimeout(() => (status = 'idle'), resetAfter);
		if (!ok) selectValue();
		/* Last, so a callback that unmounts the component finds nothing left to clean up. */
		onCopy?.({ ok });
	}

	/*
	 * A revealed value that is wider than the field fades at its trailing edge until it has been
	 * scrolled to the end, so the hidden characters announce themselves.
	 */
	let fade = $state(false);
	$effect(() => {
		const el = valueEl;
		if (!scroll || !el) {
			fade = false;
			return;
		}
		const update = () => {
			const rest = el.scrollWidth - el.clientWidth - Math.abs(el.scrollLeft);
			fade = rest > 1;
		};
		update();
		const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
		observer?.observe(el);
		el.addEventListener('scroll', update, { passive: true });
		return () => {
			observer?.disconnect();
			el.removeEventListener('scroll', update);
		};
	});

	/* After a failure, select the visible value so Ctrl+C or the context menu copies it. */
	function selectValue() {
		if (!valueEl || hidden || (display !== undefined && display !== value)) return;
		window.getSelection()?.selectAllChildren(valueEl);
	}
</script>

{#snippet copyIcon()}
	<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
		<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" stroke="currentColor" stroke-width="1.5" />
		<path
			d="M10.5 3.25A1.25 1.25 0 0 0 9.25 2h-5.5A1.75 1.75 0 0 0 2 3.75v5.5c0 .69.56 1.25 1.25 1.25"
			stroke="currentColor"
			stroke-width="1.5"
			stroke-linecap="round"
		/>
	</svg>
{/snippet}

{#snippet checkIcon()}
	<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
		<path
			d="m3.25 8.5 3 3 6.5-7"
			stroke="currentColor"
			stroke-width="1.75"
			stroke-linecap="round"
			stroke-linejoin="round"
		/>
	</svg>
{/snippet}

{#snippet alertIcon()}
	<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
		<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.75" stroke-linecap="round" />
		<circle cx="8" cy="11" r="1" fill="currentColor" />
	</svg>
{/snippet}

{#snippet eyeIcon(open: boolean)}
	<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
		<path
			d="M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8Z"
			stroke="currentColor"
			stroke-width="1.5"
			stroke-linejoin="round"
		/>
		<circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.5" />
		{#if !open}
			<path d="m2.5 13.5 11-11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
		{/if}
	</svg>
{/snippet}

<!-- The copy and check marks share one cell; the check shows only while copied. -->
{#snippet marks()}
	<span class="grid size-4 shrink-0 place-items-center" aria-hidden="true">
		{#each ['idle', 'copied'] as const as mark (mark)}
			<span
				class={[
					'col-start-1 row-start-1 grid place-items-center',
					'transition-[opacity,scale] duration-200 ease-(--_enter) motion-reduce:scale-100 motion-reduce:transition-opacity',
					(mark === 'copied') === (status === 'copied')
						? 'scale-100 opacity-100'
						: 'scale-50 opacity-0',
					mark === 'copied' && 'text-(--_accent)'
				]}
			>
				{#if mark === 'idle'}{@render copyIcon()}{:else}{@render checkIcon()}{/if}
			</span>
		{/each}
	</span>
{/snippet}

{#snippet text()}
	<!-- A revealed value scrolls sideways, so it takes focus for keyboard scrolling (WCAG 2.1.1). -->
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<span
		bind:this={valueEl}
		class={[
			'copyable-value__value min-w-0 text-(--_ink) tabular-nums',
			mono && 'font-mono',
			scroll
				? 'flex [scrollbar-width:none] items-center self-stretch overflow-x-auto whitespace-pre outline-(--_accent) focus-visible:outline-2 focus-visible:-outline-offset-2 pointer-coarse:min-h-11'
				: truncate === 'wrap' && !hidden
					? 'wrap-anywhere whitespace-pre-wrap'
					: 'flex whitespace-pre'
		]}
		dir={valueDir}
		aria-hidden={hidden || proxy ? 'true' : undefined}
		tabindex={scroll ? 0 : undefined}
		data-fade={fade ? '' : undefined}
	>
		{#if !value}
			<span class="truncate text-(--_muted) select-none">{emptyLabel}</span>
		{:else if hidden}
			<span class="copyable-value__dots tracking-[0.12em] select-none rtl:tracking-normal"
				>{'•'.repeat(12)}</span
			>
		{:else if split}
			<span class="truncate">{shown.slice(0, -tail)}</span><span class="shrink-0"
				>{shown.slice(-tail)}</span
			>
		{:else}
			<span class={[truncate !== 'wrap' && !scroll && 'truncate']}>{shown}</span>
		{/if}
	</span>
	{#if hidden}
		<span class="sr-only">{hiddenLabel}</span>
	{:else if proxy}
		<span class="sr-only select-none" dir={valueDir}>{value}</span>
	{/if}
{/snippet}

{#if boxed}
	<div
		class={['copyable-value block min-w-0', className]}
		role="group"
		aria-labelledby={labelId}
		data-status={status}
	>
		<span
			id={labelId}
			class={hideLabel
				? 'sr-only'
				: 'mb-2 block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)'}
			>{label}</span
		>
		<div
			class="copyable-value__field flex min-h-10 items-stretch overflow-hidden rounded-lg border border-(--_hairline) bg-(--_surface)"
		>
			<div
				class={[
					'flex min-w-0 flex-1 items-center bg-(--_well) px-3 text-sm leading-5',
					truncate === 'wrap' && 'py-2'
				]}
			>
				{@render text()}
			</div>
			{#if mask}
				<button
					type="button"
					class="copyable-value__button grid w-10 shrink-0 cursor-pointer place-items-center border-s border-(--_hairline) text-(--_muted) outline-(--_accent) transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_hover) hover:text-(--_ink) focus-visible:rounded-md focus-visible:outline-2 focus-visible:-outline-offset-2 active:bg-(--_press) aria-pressed:text-(--_ink) motion-reduce:transition-none pointer-coarse:min-h-11 pointer-coarse:w-11"
					aria-label={`${showLabel} ${label}`}
					aria-pressed={revealed ? 'true' : 'false'}
					onclick={() => (revealed = !revealed)}
				>
					{@render eyeIcon(!revealed)}
				</button>
			{/if}
			<button
				type="button"
				class="copyable-value__button inline-flex shrink-0 cursor-pointer items-center gap-2 rounded-e-md border-s border-(--_hairline) px-3 text-sm leading-5 font-medium text-(--_ink) outline-(--_accent) transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_hover) focus-visible:rounded-md focus-visible:outline-2 focus-visible:-outline-offset-2 active:bg-(--_press) disabled:cursor-not-allowed disabled:text-(--_muted) disabled:opacity-60 disabled:hover:bg-transparent motion-reduce:transition-none pointer-coarse:min-h-11"
				aria-label={`${status === 'copied' ? copiedLabel : copyLabel} ${label}`}
				disabled={!value}
				onclick={copy}
			>
				{@render marks()}
				<!-- Both labels share one cell, so the button keeps the width of the longer one. -->
				<span class="grid" aria-hidden="true">
					<span class={['col-start-1 row-start-1', status === 'copied' && 'invisible']}
						>{copyLabel}</span
					>
					<span class={['col-start-1 row-start-1', status !== 'copied' && 'invisible']}
						>{copiedLabel}</span
					>
				</span>
			</button>
		</div>
		<!--
			The failure message is always laid out in an invisible layer under the live one, so the
			space it needs is held from the first render and a failure moves nothing below.
		-->
		<div class="mt-2 grid text-[13px] leading-5">
			{#snippet failure()}
				<span class="grid h-lh shrink-0 place-items-center text-(--_danger)" aria-hidden="true"
					>{@render alertIcon()}</span
				>
				<span class="min-w-0 text-pretty break-words text-(--_danger)">{failedLabel}</span>
			{/snippet}
			<p class="invisible col-start-1 row-start-1 flex items-start gap-1" aria-hidden="true">
				{@render failure()}
			</p>
			<p class="col-start-1 row-start-1 flex items-start gap-1" role="status">
				{#if status === 'failed'}
					{@render failure()}
				{:else if status === 'copied'}
					<span class="sr-only">{copiedLabel}</span>
				{/if}
			</p>
		</div>
	</div>
{:else}
	<span
		class={[
			'copyable-value inline-flex max-w-full flex-wrap items-center gap-x-1 align-middle',
			className
		]}
		role="group"
		aria-labelledby={labelId}
		data-status={status}
	>
		<span id={labelId} class={hideLabel ? 'sr-only' : 'me-1 text-(--_muted)'}>{label}</span>
		<!-- Value and buttons stay on one row; the value shortens before a button wraps. -->
		<span class="flex max-w-full min-w-0 items-center gap-x-1">
			<span class="flex min-w-0 items-center">{@render text()}</span>
			{#if mask}
				<button
					type="button"
					class="copyable-value__button grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-(--_muted) outline-(--_accent) transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_hover) hover:text-(--_ink) focus-visible:outline-2 focus-visible:outline-offset-2 active:bg-(--_press) aria-pressed:text-(--_ink) motion-reduce:transition-none pointer-coarse:size-11"
					aria-label={`${showLabel} ${label}`}
					aria-pressed={revealed ? 'true' : 'false'}
					onclick={() => (revealed = !revealed)}
				>
					{@render eyeIcon(!revealed)}
				</button>
			{/if}
			<button
				type="button"
				class="copyable-value__button grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-(--_muted) outline-(--_accent) transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_hover) hover:text-(--_ink) focus-visible:outline-2 focus-visible:outline-offset-2 active:bg-(--_press) disabled:cursor-not-allowed disabled:hover:bg-transparent motion-reduce:transition-none pointer-coarse:size-11"
				aria-label={`${copyLabel} ${label}`}
				disabled={!value}
				onclick={copy}
			>
				{@render marks()}
			</button>
		</span>
		<!-- The check in the button confirms a copy; a failure takes its own line under the row. -->
		<span class="basis-full text-[13px] leading-5 text-pretty text-(--_danger)" role="status"
			>{#if status === 'failed'}{failedLabel}{:else if status === 'copied'}<span class="sr-only"
					>{copiedLabel}</span
				>{/if}</span
		>
	</span>
{/if}

<style>
	/* Public tokens: set --copyable-value-* on the component or any ancestor to retone it. */
	.copyable-value {
		--_accent: var(--copyable-value-accent, #18181b);
		--_danger: var(--copyable-value-danger, #b91c1c);
		--_ink: var(--copyable-value-ink, #18181b);
		--_muted: var(--copyable-value-muted, #52525b);
		--_hairline: var(--copyable-value-hairline, rgb(0 0 0 / 0.12));
		--_surface: var(--copyable-value-surface, #ffffff);

		/* Formulas: the read-only well and the button's hover and press fills follow any retone. */
		--_well: color-mix(in oklab, var(--_surface), var(--_ink) 3%);
		--_hover: color-mix(in oklab, var(--_surface), var(--_ink) 6%);
		--_press: color-mix(in oklab, var(--_surface), var(--_ink) 11%);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
		--_enter: cubic-bezier(0.16, 1, 0.3, 1);
	}

	/* The trailing-edge fade has no utility spelling; it follows the value's own direction. */
	.copyable-value__value[data-fade] {
		mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
	}
	.copyable-value__value[data-fade][dir='rtl'] {
		mask-image: linear-gradient(to left, #000 calc(100% - 2rem), transparent);
	}

	/* Forced colours drop fills, so the field keeps a system-colour edge and focus stays visible. */
	@media (forced-colors: active) {
		.copyable-value__field {
			border-color: CanvasText;
		}
		.copyable-value__button:focus-visible {
			outline-color: Highlight;
		}
	}
</style>

Pass a label and the value; the component renders it as selectable text with a Copy button that writes exactly that value, confirms in place and announces the result. Masking only hides the value on screen: it is still in the page source and the DOM, so never use it to protect a secret the viewer should not have. It does not generate, rotate or fetch keys.

Suggested location
src/lib/components/copyable-value-01
Required props
valuelabel

Limitations

  • Copying needs JavaScript. Before hydration, or with scripts off, the value is still ordinary text that can be selected and copied by hand.
  • Browsers only allow clipboard writes from a user action on a secure (https or localhost) page. Elsewhere the execCommand fallback is tried, and if that is blocked too the failure message shows and the value is selected.
  • Masking is visual only. The value is in the HTML and the DOM, and Copy writes it without revealing it.
  • Middle truncation is by width: the start shortens with an ellipsis and the last tailLength characters stay. Pass display for a fixed short form instead; manual selection then copies the display text.
  • The boxed layout lays out the failure message invisibly from the first render, so a failure moves nothing below. The inline layout keeps its row compact: a copy shows a check in the button, and a failure adds its message on a line under the row.
  • A revealed masked value is shown whole on one line that scrolls sideways (and takes keyboard focus to scroll), rather than truncated.

Example

Svelte
<script lang="ts">
	import CopyableValue from '$lib/components/copyable-value-01/CopyableValue.svelte';
</script>

<div class="max-w-sm">
	<CopyableValue label="Order number" value="HAL-2026-08417" />
	<CopyableValue
		label="API key"
		value="hlc_live_4fT9kQ2mX8vR1nB7cW3zP6"
		mono
		mask
		onCopy={({ ok }) => console.log(ok ? 'copied' : 'copy failed')}
	/>
</div>

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.

Copyable value#

One value, one Copy button, one confirmation. Use it for order and invoice numbers on receipts, IDs and keys on settings pages, wallet addresses, and codes people paste somewhere else.

Boxed or inline#

layout="boxed" (the default) sets the value in a tinted read-only well with Copy attached inside the same hairline, and holds one line under the field for a failure message. Use it on its own or stacked in a settings panel.

layout="inline" runs the value in a row of text with a 28 px icon button, for a details list, a table cell or a sentence. Set hideLabel when the row already names the value; the label still names the group and the button for screen readers.

What gets copied#

Always value, exactly. display and truncation change only what is drawn:

  • truncate="middle" shortens by width with a CSS ellipsis and keeps the last tailLength characters, so the ellipsis is never copied, even when someone selects the text by hand.
  • truncate="end" is a plain end ellipsis; truncate="wrap" breaks the value across lines.
  • display draws a fixed short form of your own. Copy still writes value, but a manual selection copies the display text, so prefer truncate when people might select it.

When copying fails#

The Clipboard API needs a user action on a secure page. copy-text.ts tries it, then the legacy execCommand('copy') path. If both fail, failedLabel shows and is announced, the value is selected so Ctrl+C or the context menu copies it, and onCopy receives { ok: false }. The failure stays until the next attempt; a success resets after resetAfter milliseconds.

copy-text.ts has no dependencies on the component. Copy it into a code block or command snippet that needs the same behaviour.

Masking#

mask swaps the value for twelve dots and adds a Show toggle. It is a screen-sharing courtesy, not security: the value is in the HTML, and Copy writes it without revealing it. Bind revealed to control or read the toggle.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
valuestringYesNoneThe exact text written to the clipboard. An empty string shows emptyLabel and disables Copy.
labelstringYesNoneNames the value and both buttons: 'Copy {label}' and 'Show {label}'. Visible unless hideLabel is set.
displaystringNoNoneShown instead of value when they differ, such as a shortened form. Copy still writes value, and screen readers hear value.
monobooleanNofalseMonospace type for identifiers, keys and addresses.
maskbooleanNofalseShows dots and a Show toggle (aria-pressed). Revealed, the value is shown whole on one line that scrolls. Visual only: the value is still in the page.
revealedbooleanNofalseWhether a masked value is showing. Bindable.
layout'boxed' | 'inline'No'boxed''boxed' sets the value in a field with Copy attached; 'inline' runs it in a row of text with a small icon button.
truncate'middle' | 'end' | 'wrap'No'middle'How a value too long for its width shortens. 'middle' keeps the start and the last tailLength characters; 'wrap' breaks it across lines.
tailLengthnumberNo6Characters kept at the end when truncate is 'middle'.
hideLabelbooleanNofalseKeep the label for assistive technology only, when the row or heading already names the value.
copyLabelstringNo'Copy'Visible button text and the start of its accessible name.
copiedLabelstringNo'Copied'Confirmation after a successful copy, shown in the button (and in its name) and announced.
failedLabelstringNo'Copy failed. Copy the text manually.'Message after a failed copy, shown under the field and announced. The value is selected when it is visible.
emptyLabelstringNo'Not available yet'Shown in the field, muted, when value is empty, such as a tracking number not issued yet.
showLabelstringNo'Show'Start of the reveal toggle's name: 'Show {label}'.
hiddenLabelstringNo'Hidden'Read by screen readers in place of a masked value.
resetAfternumberNo2000Milliseconds before Copied returns to rest; 0 keeps it. A failure stays until the next attempt.
valueDir'ltr' | 'rtl' | 'auto'No'ltr'Direction of the value itself. Identifiers stay left to right on a right-to-left page.
onCopy(result: { ok: boolean }) => voidNoNoneCalled after every copy attempt with whether the text reached the clipboard.
classstringNoNoneExtra classes on the root, for placement.

Customization#

On this page

Change every word through props and retone the field through six --copyable-value-* CSS variables. The well, hover and press fills are mixed from surface and ink, so they follow any retone.

  • Accent: --copyable-value-accent colours the focus ring and the Copied check. The blue palette sets it to #1d4ed8; keep it at 3:1 against the surface.
  • Danger: --copyable-value-danger colours the failure mark and message. The default is red-700 (6.5:1 on white).
  • Neutrals: --copyable-value-ink is the label, value and button text, --copyable-value-muted the inline label and icon buttons, --copyable-value-hairline the field's edge and the divider before each button, --copyable-value-surface the button fill (the value well is surface mixed 3% toward ink).
  • Worked retone for a #09090b band: --copyable-value-surface: #18181b; --copyable-value-ink: #fafafa; --copyable-value-muted: #a1a1aa; --copyable-value-hairline: rgb(255 255 255 / 0.12); --copyable-value-accent: #fafafa; --copyable-value-danger: #f87171.
  • Cream page such as a bakery's #fbf7f0: set --copyable-value-surface: #fffdf9 and leave the hairline alone; it is alpha black, so it tints with the page.
  • Width: the boxed field fills its container. Put it in a max-w-sm or max-w-md column, or a grid cell; stack several with a 12 px gap (the held message line adds the rest).
  • Inline: use layout 'inline' inside a details list, table cell or sentence, and hideLabel when the row already names the value.
  • Long values: keep truncate 'middle' for hashes and addresses, raise tailLength when the end is what people check, or use 'wrap' for links people should read in full.
  • Languages: translate copyLabel, copiedLabel, failedLabel, showLabel and hiddenLabel with the page. Keep valueDir 'ltr' for identifiers on right-to-left pages.

Public CSS variables

VariableToken
--copyable-value-accentaccent
--copyable-value-dangerdanger
--copyable-value-inkink
--copyable-value-mutedmuted
--copyable-value-hairlinehairline
--copyable-value-surfacesurface

Accessibility#

On this page
  • The root is a group named by the label, which stays in the accessibility tree when hideLabel hides it visually.
  • The Copy button is named 'Copy {label}' (copyLabel then label), and 'Copied {label}' while the boxed button shows Copied, so the name always starts with the visible text. Focus stays on it after a copy.
  • A polite role=status region is in the page from the first render; it announces copiedLabel or failedLabel after each attempt, and a second copy is announced again.
  • Success is a check mark and the word Copied, and failure an icon and a sentence, never colour alone.
  • Middle-truncated or display text is hidden from assistive technology and the whole value is read from a visually hidden copy, which is excluded from manual selection so a drag-copy is not doubled.
  • A masked value is hidden from assistive technology, which hears hiddenLabel; the Show toggle keeps one name and reports its state with aria-pressed.
  • Buttons are 40 px tall in the boxed field (44 px on touch screens) and 28 px inline (44 px on touch screens), with a two-pixel accent focus ring.
  • Your responsibility: do not rely on mask to keep a value secret, and translate the labels with the page.

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 · 21 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.