Primitives Actions
Copy
Copy to clipboard as one attribute, data-slean-copy, on a button. One shared click listener writes the value with the Async Clipboard API, marks the button as copied for a moment and dispatches slean:copy. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1148 B brotli · 1301 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - 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>
On this page
Example
inv_8f2a1c <code>inv_8f2a1c</code>
<button type="button" data-slean="button" data-variant="ghost" data-icon-only
data-slean-copy="inv_8f2a1c" aria-label="Copy the invoice id"
data-slean-hint="Copy" data-slean-hint-copied="Copied">
<span data-slean-part="icon"><svg …></svg></span>
</button>
<button type="button" data-slean="button" data-variant="outline"
data-slean-copy="yarn add @svelte-lean/primitives">
<span data-slean-part="icon"><svg …></svg></span>
Copy the install command
</button><script lang="ts">
// With @svelte-lean/vite the register imports are injected for the data-slean-copy and
// data-slean-hint attributes, static or dynamic.
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/hint.css';
import '@svelte-lean/styles/copy.css';
let { id }: { id: string } = $props();
</script>
<code>{id}</code>
<button type="button" data-slean="button" data-variant="ghost" data-icon-only
data-slean-copy={id} aria-label="Copy the invoice id"
data-slean-hint="Copy" data-slean-hint-copied="Copied">
<span data-slean-part="icon"><svg …></svg></span>
</button>Why this implementation exists
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.
The behavior registers one click handler with the shared router for every copy element on the page. The copied state is an attribute on the element, data-slean-copied, removed by the timer of the latest 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
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every data-slean-copy attribute in the markup, with a static or a dynamic
value; without it, import the
register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/copy/register';import '@svelte-lean/styles/copy.css';Put data-slean-copy="<value>" on a <button type="button">: the button makes the copy 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, not only in the attribute, so it can be selected by hand where 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 link, button or field inside a copy element belongs to that control; when copy elements nest, the innermost one copies its own value.
<script lang="ts">
import type { CopyDetail } from '@svelte-lean/primitives/copy';
let list: HTMLElement;
let status = $state('');
$effect(() => {
// slean:copy bubbles from the element once the value is on the clipboard. One listener on
// an ancestor serves every copy element inside it.
const oncopy = (event: Event) => {
status = `Copied ${(event as CustomEvent<CopyDetail>).detail.value}`;
};
list.addEventListener('slean:copy', oncopy);
return () => list.removeEventListener('slean:copy', oncopy);
});
</script>
<ul bind:this={list}>…</ul>
<!-- The behavior adds no ARIA; a live region announces the copy to assistive technology. -->
<p role="status">{status}</p>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. |
Runtime profile
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.
Accessibility contract
- 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 forslean:copyand 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.
Keyboard
| Key | When | Result |
|---|---|---|
| Enter/Space | focus on a <button> copy element | Activates the button (native), which copies the value |
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
Render the attributes as they are. Nothing runs on the server or at import.
Before hydration
Before the behavior loads, a click copies nothing. The registration attaches no per-element state, so the first click after hydration copies.
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 | oklch(55% 0.15 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.
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);Compatibility notes
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.
Examples
Ids in a list
Each id is the text of its own copy button, so it stays visible and selectable by hand. The
status line below the list is application code: one slean:copy listener on the list
writes the copied value into a role="status" region, which is how a copy reaches assistive
technology.
- Amara Okafor
- Jonas Lindqvist
- Mei Tanaka
{#each invoices as invoice (invoice.id)}
<li>
{invoice.customer}
<!-- The id is the visible text, so it can be selected by hand without JavaScript. -->
<button type="button" data-slean="button" data-variant="ghost" data-size="sm"
data-slean-copy={invoice.id} data-slean-hint="Copy" data-slean-hint-copied="Copied">
<code>{invoice.id}</code>
</button>
</li>
{/each}Testing
packages/primitives/tests/copy.test.tsVitest: the value, the mark and its duration, the copied hint, nested controls, disabled, a refused clipboard, 1000 elementsapps/playground/tests/primitives/hint.spec.tsPlaywright: hint and copy in Chrome, the top layer, keyboard focus, Escape, the flip, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/copy/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/copy/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/copy/behavior.tsthe behavior definition and copyValue()packages/primitives/src/copy/register.tsthe registration modulepackages/primitives/src/copy/validate.tsdevelopment validation messagesdocs/adr/0009-attribute-behaviors.mdwhy an attribute on any element can be the root of a behaviorpackages/styles/css/copy.cssthe optional stylesheet