Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself.
Components / UI
Copy Button
A button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration. The clipboard write is the only script; it sets one attribute, data-state, and writes the outcome into a visually hidden status node rendered after the button, so a screen reader hears it; the icon (AnimatedIcons) and the label faces are CSS. When the clipboard refuses, the button says and announces "Copy failed".
Live demo
npx astro add moonarcLive, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
837 B raw
489 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.5 kB raw.
CSS 9.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 66 · Firefox 63 · Safari 13.1; needs clipboard. Elsewhere: the error face, because the clipboard API needs a secure context.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import CopyButton from '@moonarc/core/CopyButton';
---
<CopyButton value="npx astro add moonarc" />
<pre id="snippet">pnpm add @moonarc/core</pre>
<CopyButton target="#snippet" compact />Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the shared runtime, Animated Icons and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/copy-button.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and AnimatedIcons.astro.
---
/**
* CopyButton — click copies, the icon turns into a check, the label says so,
* and both come back. The one thing that needs a script is the clipboard
* (navigator.clipboard.writeText): the button carries the value or a
* selector for the element whose text to copy, and the script writes one
* attribute, data-state, and one line of status text. The icon is
* AnimatedIcons reading that attribute;
* the label faces are CSS. A refused clipboard (permission, insecure
* context) is said out loud — "Copy failed" — not swallowed. The done and
* error faces hold for the ambient duration token, then return to idle.
* Two nodes, not one: the button, named by its visible face only, and right
* after it a visually hidden role="status" span the script writes the done
* or error label into — the announcement, which a face that only changes
* opacity never was.
*/
import type { HTMLAttributes } from 'astro/types';
import AnimatedIcons from './AnimatedIcons.astro';
interface Props extends HTMLAttributes<'button'> {
/** Text to copy. */
value?: string;
/** Or: selector of the element whose textContent to copy. */
target?: string;
label?: string;
doneLabel?: string;
errorLabel?: string;
/** Icon only; the label stays as the accessible name. */
compact?: boolean;
}
const { value, target, label = 'Copy', doneLabel = 'Copied', errorLabel = 'Copy failed', compact = false, class: className, ...rest } = Astro.props;
// unique per render: two CopyButtons on a page (CodeTabs renders one per panel) each announce into their own node
const statusId = `ma-copy-status-${Math.random().toString(36).slice(2, 7)}`;
---
<button
class:list={['ma-copy', compact && 'ma-copy--compact', className]}
type="button"
data-ma-copy={statusId}
data-value={value}
data-target={target}
data-state="idle"
aria-label={compact ? label : undefined}
{...rest}
>
<AnimatedIcons icon="copy" />
<span class="ma-copy__label" aria-hidden={compact ? 'true' : undefined}>
<span class="ma-copy__face" data-face="idle">{label}</span>
<span class="ma-copy__face" data-face="done">{doneLabel}</span>
<span class="ma-copy__face" data-face="error">{errorLabel}</span>
</span>
</button>
<span class="ma-sr" role="status" id={statusId}></span>
<style is:global>
@layer components {
:where(.ma-copy) {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.45em 0.8em;
border: 1px solid var(--ma-edge);
border-radius: 0.5em;
background: var(--ma-panel);
color: var(--ma-ink);
font: inherit;
font-size: 0.875em;
line-height: 1.2;
white-space: nowrap;
cursor: copy;
transition:
border-color var(--ma-duration-fast) var(--ma-ease-out),
color var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-copy:hover) {
border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
}
:where(.ma-copy:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
:where(.ma-copy--compact) {
padding: 0.45em;
}
:where(.ma-copy--compact .ma-copy__label) {
display: none;
}
:where(.ma-copy__label) {
display: inline-grid;
}
/* inactive faces leave the accessibility tree, so the button is named by the one that shows. visibility transitions
with the fade: a face that arrives is visible at once, one that leaves stays until it has faded. It runs on
ease-out, not the preset curve — an overshooting spring would flip it hidden and back mid-fade. Hidden faces keep
their grid cell, so the width stays. */
:where(.ma-copy__face) {
grid-area: 1 / 1;
opacity: 0;
visibility: hidden;
translate: 0 var(--ma-travel-hover);
transition:
opacity var(--ma-duration) var(--ma-ease),
translate var(--ma-duration) var(--ma-ease),
visibility var(--ma-duration) var(--ma-ease-out);
}
:where(.ma-copy[data-state='idle'] [data-face='idle']),
:where(.ma-copy[data-state='done'] [data-face='done']),
:where(.ma-copy[data-state='error'] [data-face='error']) {
opacity: 1;
visibility: visible;
translate: 0 0;
}
/* the state tints are hooks: on a coloured ground a caller points --ma-copy-done / --ma-copy-error at a colour that
keeps 4.5 : 1 there (the words carry the state either way) */
:where(.ma-copy[data-state='done']) {
color: var(--ma-copy-done, color-mix(in srgb, var(--ma-ink) 55%, #3a9a5b));
}
:where(.ma-copy[data-state='error']) {
color: var(--ma-copy-error, color-mix(in srgb, var(--ma-ink) 50%, #d64a2e));
}
/* JavaScript off: nothing can reach the clipboard, so the button would do nothing. The text it copies is still on the
page to select */
@media (scripting: none) {
:where(.ma-copy) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-copy__face) {
transition: none;
translate: 0 0;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLButtonElement>('[data-ma-copy]', (btn, { signal }) => {
let timer: number | undefined;
// data-ma-copy holds the id of this instance's status node, the sibling rendered after the button
const status = document.getElementById(btn.dataset.maCopy!);
// the hold time is the ambient duration token, read from the element so a preset or an override applies
const hold = () => {
const v = getComputedStyle(btn).getPropertyValue('--ma-dur-ambient').trim();
const n = parseFloat(v);
return n ? (v.endsWith('ms') ? n : n * 1000) : 1400;
};
const say = (text: string) => {
if (!status) return;
if (text && status.textContent === text) {
// the same text again is no change to assistive tech: empty the node, let a frame render it empty, then write
status.textContent = '';
requestAnimationFrame(() => requestAnimationFrame(() => (status.textContent = text)));
} else status.textContent = text;
};
// back to idle, and the status empties: a later copy is a change again, and no stale "Copied" is left to browse to
const idle = () => {
btn.dataset.state = 'idle';
say('');
};
const settle = (state: 'done' | 'error') => {
btn.dataset.state = state;
say(btn.querySelector(`[data-face='${state}']`)!.textContent!);
clearTimeout(timer);
timer = window.setTimeout(idle, hold());
};
btn.addEventListener(
'click',
async () => {
const text = btn.dataset.value ?? (btn.dataset.target ? document.querySelector(btn.dataset.target)?.textContent : '') ?? '';
try {
await navigator.clipboard.writeText(text);
settle('done');
} catch {
settle('error');
}
},
{ signal },
);
// torn down before a swap: a button that survives it (transition:persist) goes back to idle rather than keeping the
// "Copied" whose reset timer this just cleared
signal.addEventListener('abort', () => {
clearTimeout(timer);
idle();
});
});
</script>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | Text to copy. |
target | string | none | Or a selector; the element's textContent is copied. |
label | string | 'Copy' | Idle label; the accessible name in compact mode. |
doneLabel | string | 'Copied' | Shown after a successful write. |
errorLabel | string | 'Copy failed' | Shown when the clipboard refuses. |
compact | boolean | false | Icon only; the label stays as aria-label, and the status node still announces done and failed. |
--ma-copy-done | CSS colour (custom property) | 55 % ink, 45 % green (srgb) | The button's colour (label, icon, focus ring) while it says it copied. Set it in a rule or style on the button or any ancestor that knows its ground. Use a colour that keeps 4.5 : 1 against it, for example the ground's own text colour on a coloured panel, where the green mix falls short. The words say the state either way. |
--ma-copy-error | CSS colour (custom property) | 50 % ink, 50 % red (srgb) | The same for the failed state. |
Reduced motion
The faces switch at once and the check is drawn without its stroke animation.
With ClientRouter
Bound through the shared runtime: the listener is attached after every navigation and aborted before the swap. The abort clears a pending reset and puts the button back to idle, so one kept across the swap by transition:persist does not stay on "Copied".
Why it is built this way
- The hold time is the ambient duration token, read from the element at click time. It is long enough to be read and short enough that a second copy is not blocked by the first.
- The icon needs no script of its own: AnimatedIcons reads data-state="done" on the button.
- Failure is a visible state: a page served over http, or a browser that asks, gets "Copy failed" and the text stays selectable.
- With JavaScript off the button is display: none (@media (scripting: none)): it could only be a button that does nothing, and the text it would copy is still on the page to select. Engines without the scripting media feature keep showing it.
- Faces stack in one grid cell and cross-fade with a 4 px rise, so the button never changes width when the label does.
- The button is named by the face that shows: inactive faces are visibility: hidden, which takes them out of the accessibility tree but not out of their grid cell. visibility transitions with the fade on ease-out, so an arriving face counts at once and a leaving one until it has faded; an overshooting preset curve would flip it mid-fade.
- The outcome is announced by a role="status" span (.ma-sr) rendered after the button, not aria-live on the button: a live button whose faces only change opacity announces nothing. The script writes the done or error label into it, empties it when the button returns to idle, and empties it for a frame before writing the same label again, so a second copy is heard too.
- The component renders two nodes: the button, then its status span. class and every other attribute go to the button. The span is absolutely positioned and clipped, so flex and grid gaps do not see it, but child selectors do. The button is no longer :last-child (a Tailwind space-x-* parent gives it a trailing margin), and a + sibling rule after the button meets the span first. Wrap the component in an element of its own to place it, as CodeTabs does.
Replaces: CopyButton (Magic UI) · CopyButton (Cult UI) · copy button (shadcn examples). See the migration table.
Related
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarcA code block with tabs (for example npm, pnpm, bun) and a copy button per panel.
idle → pending → done · one attribute
One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown