# Copy Button (Moonarc)

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".

- Import: `import CopyButton from '@moonarc/core/CopyButton'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/copy-button.json`
- Tier B · category ui · trigger click
- Readout: `<CopyButton value="npx astro add moonarc">`
- Browser support: widely (Chrome 66 · Firefox 63 · Safari 13.1); elsewhere: the error face, because the clipboard API needs a secure context
- Measured cost: 837 B raw JS · 489 B gzip · + runtime (with dependencies 2.5 kB raw; CSS 9.7 kB raw)
- Page: https://moonarc.dev/components/copy-button/

## 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. |

## Usage

```astro
<CopyButton value="npx astro add moonarc" />
<pre id="snippet">pnpm add @moonarc/core</pre>
<CopyButton target="#snippet" compact />
```

## 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".

## Craft

- 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)

## Source

```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>

```
