Skip to content

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 moonarc
click · check · back

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

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

Baseline · widely available

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 moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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.

terminal
npx shadcn@latest add https://moonarc.dev/r/copy-button.json

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

PropTypeDefaultDescription
valuestringnoneText to copy.
targetstringnoneOr a selector; the element's textContent is copied.
labelstring'Copy'Idle label; the accessible name in compact mode.
doneLabelstring'Copied'Shown after a successful write.
errorLabelstring'Copy failed'Shown when the clipboard refuses.
compactbooleanfalseIcon only; the label stays as aria-label, and the status node still announces done and failed.
--ma-copy-doneCSS 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-errorCSS 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

Replaces: CopyButton (Magic UI) · CopyButton (Cult UI) · copy button (shadcn examples). See the migration table.

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.

Chrome 52 · Firefox 97 · not Safariclick
npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc

A code block with tabs (for example npm, pnpm, bun) and a copy button per panel.

every browserclick

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.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown