# Copy

> A data-slean-copy attribute on a button: one shared click listener writes the value to the clipboard and marks the button as copied for a moment.

Page: https://svelte-lean.vercel.app/primitives/copy · Markdown: https://svelte-lean.vercel.app/primitives/copy.md · Index: https://svelte-lean.vercel.app/llms.txt

Copy to clipboard as one attribute, data-slean-copy, on a button. Use it for ids, links, commands and code.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register imports are injected for the data-slean-copy and
	// data-slean-hint attributes.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/hint.css';
	import '@svelte-lean/styles/copy.css';
</script>

{#snippet icon()}
	<span data-slean-part="icon">
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
			<rect width="14" height="14" x="8" y="8" rx="2" />
			<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
		</svg>
	</span>
{/snippet}

<div class="row">
	<span class="value">
		<code>inv_8f2a1c</code>
		<button
			type="button"
			data-slean="button"
			data-variant="text"
			data-color="neutral"
			data-icon-only
			data-slean-copy="inv_8f2a1c"
			aria-label="Copy the invoice id"
			data-slean-hint="Copy"
			data-slean-hint-copied="Copied"
		>
			{@render icon()}
		</button>
	</span>
	<button
		type="button"
		data-slean="button"
		data-variant="outlined"
		data-color="neutral"
		data-slean-copy="yarn add @svelte-lean/primitives"
	>
		{@render icon()}
		Copy the install command
	</button>
</div>

<style>
	.row {
		display: flex;
		flex-wrap: wrap;
		gap: 1.5rem;
		align-items: center;
	}

	.value {
		display: inline-flex;
		align-items: center;
		gap: 0.25rem;
	}
</style>
```

Click a button or press Enter on it: the icon turns into a check mark for a moment. The clipboard needs a secure context.

## Examples

### Input with a copy button

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/input.css';
	import '@svelte-lean/styles/copy.css';

	const link = 'https://example.com/invite/7Hk2pQ';
</script>

{#snippet icon()}
	<span data-slean-part="icon">
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
			<rect width="14" height="14" x="8" y="8" rx="2" />
			<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
		</svg>
	</span>
{/snippet}

<label for="invite-link">Invite link</label>
<div class="share">
	<input id="invite-link" type="url" data-slean="input" value={link} readonly />
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-slean-copy={link}>
		{@render icon()}
		Copy
	</button>
</div>

<style>
	.share {
		display: flex;
		gap: 0.5rem;
		max-inline-size: 28rem;
		margin-block-start: 0.375rem;
	}

	.share input {
		flex: 1;
	}
</style>
```

### Code snippet

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/hint.css';
	import '@svelte-lean/styles/copy.css';

	const command = 'yarn add -D @svelte-lean/vite';
</script>

{#snippet icon()}
	<span data-slean-part="icon">
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
			<rect width="14" height="14" x="8" y="8" rx="2" />
			<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
		</svg>
	</span>
{/snippet}

<div class="snippet">
	<pre><code>{command}</code></pre>
	<button
		type="button"
		data-slean="button"
		data-variant="text"
		data-color="neutral"
		data-size="small"
		data-icon-only
		data-slean-copy={command}
		aria-label="Copy the command"
		data-slean-hint="Copy"
		data-slean-hint-copied="Copied"
	>
		{@render icon()}
	</button>
</div>

<style>
	.snippet {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 0.5rem;
		max-inline-size: 28rem;
		padding: 0.5rem 0.5rem 0.5rem 1rem;
		border: 1px solid var(--slean-border);
		border-radius: var(--slean-radius-md);
		background: var(--slean-muted);
	}

	.snippet pre {
		margin: 0;
		overflow-x: auto;
		font-family: var(--slean-font-mono);
		font-size: var(--slean-text-sm);
	}
</style>
```

### Ids in a list

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/hint.css';
	import '@svelte-lean/styles/copy.css';
	import type { CopyDetail } from '@svelte-lean/primitives/copy';
	import { on } from 'svelte/events';

	const invoices = [
		{ id: 'inv_8f2a1c', customer: 'Amara Okafor' },
		{ id: 'inv_91b40e', customer: 'Jonas Lindqvist' },
		{ id: 'inv_a3c7d2', customer: 'Mei Tanaka' }
	];
	let status = $state('');

	// slean:copy bubbles once the value is on the clipboard: one listener serves the list.
	function copied(event: Event) {
		status = `Copied ${(event as CustomEvent<CopyDetail>).detail.value}`;
	}
</script>

<ul class="ids" {@attach (node) => on(node, 'slean:copy', copied)}>
	{#each invoices as invoice (invoice.id)}
		<li>
			<span>{invoice.customer}</span>
			<!-- The id is the visible text, so it can be selected by hand without JavaScript. -->
			<button
				type="button"
				data-slean="button"
				data-variant="text"
				data-color="neutral"
				data-size="small"
				data-slean-copy={invoice.id}
				data-slean-hint="Copy"
				data-slean-hint-copied="Copied"
			>
				<code>{invoice.id}</code>
			</button>
		</li>
	{/each}
</ul>
<!-- The behavior adds no ARIA; a live region announces the copy. -->
<p role="status">{status}</p>

<style>
	.ids {
		display: grid;
		gap: 0.5rem;
		max-inline-size: 24rem;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.ids li {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 0.75rem;
	}
</style>
```

One slean:copy listener on the list writes the copied value into a live region.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean-copy` attribute; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/copy/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/copy.css';
```

Put `data-slean-copy="<value>"` on a `<button type="button">`, so the copy is reachable from the keyboard; any other element works for the pointer only, and development validation warns about it. Keep the value visible in the page, where it can be selected by hand if the clipboard is unavailable.

With `data-slean-hint` and `data-slean-hint-copied` (the Hint primitive), a shown hint reads the copied label while the button is marked. A click on a control inside a copy element belongs to that control; nested copy elements copy their own value.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `any element with data-slean-copy="…"` | – | yes | A \<button type="button"\> is recommended: it makes the copy reachable from the keyboard. With data-slean-hint and data-slean-hint-copied the hint reads the copied label meanwhile. |
| icon | `<span data-slean-part="icon">` | `icon` | styles only | A wrapper around the copy icon. copy.css swaps it for a check mark while the element is copied. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Enter`/`Space` | focus on a \<button\> copy element | Activates the button (native), which copies the value |

## Styling

`copy.css` sets the pointer cursor (not-allowed when disabled) and, while `data-slean-copied` is present, hides the svg inside `[data-slean-part="icon"]` and shows the check mark mask from `--slean-icon-check` in the success color. The button keeps its own look from `button.css`. Select on the presence of `data-slean-copied`; its value is an internal stamp. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-success-main` | `oklch(49% 0.13 150)` | color |
| `--slean-icon-check` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `[aria-disabled="true"]`.

Variant attributes: `data-slean-copy`; `data-slean-copied`.

## Controlled integration

`copyValue(element, value?)` runs the same copy from code: it writes the value (the element’s `data-slean-copy` by default), marks the element, redraws its hint and dispatches `slean:copy`, and resolves to whether the value was written.

`share.ts`

```ts
import { copyValue } from '@svelte-lean/primitives/copy';

// The same copy from code: marks the element, redraws its hint and dispatches slean:copy.
// Resolves to false when the clipboard is missing or refused.
const copied = await copyValue(button, link.href);
```

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1291 B brotli · 1464 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `navigator.clipboard.writeText()`, `<button>`, `data-slean-copied`
- **Shared listeners**: click
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `navigator.clipboard.writeText + <button>`

The copy registers one `click` handler with the shared router. A copy writes one attribute on the element and starts one timer that removes it; a second copy within the duration re-stamps the attribute, so only the latest timer clears it. No layout is read.

Why this implementation

A copy button is a click handler, a clipboard call and a moment of feedback. Written per button, it is a function and a timer for every id in a table. The browser owns the clipboard and the button; what it has no attribute for is the value to write and the feedback after it.

One shared `click` listener serves every copy element on the page: it writes the value with the Async Clipboard API, marks the element with `data-slean-copied` for a moment and dispatches `slean:copy`. Nothing is kept per element (`tests/copy.test.ts` keeps one click listener for a thousand copy elements).

### The browser owns

- the clipboard: writing the text, the permission and the secure-context rule
- the button: focus, Enter and Space, disabled

### Svelte Lean owns

- one shared click listener for every copy element on the page; no state per element
- the copied mark: data-slean-copied for a moment after a successful copy, restarted by a second copy
- slean:copy with the value; a copied label in a shown hint
- leaving a click on a nested link or button to that control; the innermost copy element wins
- copy.css: the pointer, and the check mark in place of the icon while copied; development validation

Accessibility

- The element is a native `<button>`: focusable, activated by Enter and Space. The behavior never moves focus.
- The behavior adds no ARIA. The success is shown visually (`data-slean-copied`, the check mark, the copied hint); an application that must announce it listens for `slean:copy` and writes to a live region.
- A disabled element (`disabled`, `aria-disabled="true"`) copies nothing.
- An icon-only copy button needs its own accessible name (`aria-label`); the hint is supplementary.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Async Clipboard API` | Baseline widely available since 2020 (Chrome 66, Firefox 63, Safari 13.1) | Secure contexts only; elsewhere a click copies nothing and dispatches nothing |
| `<button> keyboard activation` | Baseline widely available | Any other element works for the pointer only |

Without JavaScript

Clicking copies nothing. The value stays in the page, where it can be selected and copied by hand.

Server rendering and hydration

Render the attributes as they are. Nothing runs on the server or at import.

Before the behavior loads, a click copies nothing. The registration attaches no per-element state, so the first click after hydration copies.

Compatibility

The Async Clipboard API is Baseline widely available and works in secure contexts only (https or localhost); Safari and Firefox require a user activation, which the click provides. A missing or refused clipboard sets no mark, dispatches no event and logs nothing. There is no fallback through document.execCommand('copy'), no rich clipboard content and no reading from the clipboard.

Tests and source

- `packages/primitives/tests/copy.test.ts` Vitest: the value, the mark and its duration, the copied hint, nested controls, disabled, a refused clipboard, 1000 elements
- `apps/playground/tests/primitives/hint.spec.ts` Playwright: hint and copy in Chrome, the top layer, keyboard focus, Escape, the flip, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/copy/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/copy/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/copy/behavior.ts` the behavior definition and copyValue()
- `packages/primitives/src/copy/register.ts` the registration module
- `packages/primitives/src/copy/validate.ts` development validation messages
- `docs/adr/0009-attribute-behaviors.md` why an attribute on any element can be the root of a behavior
- `packages/styles/css/copy.css` the optional stylesheet
