# Clipboard Button (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-clipboard-button` · version 1.0.0 · component · free
- category: Actions
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-clipboard-button.json`
- npm dependencies: none
- registry dependencies: utils, @beautiful-ui/lumen-button, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-clipboard-button
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Copies text and says so: the copy icon becomes a check that draws itself in, the label reads Copied, and screen readers hear it. Falls back when the clipboard is blocked, and the value can be made on press, even asynchronously.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/clipboard-button.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-button`, `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `ClipboardButton`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`). Never add Tailwind colour classes inside the component.

```tsx
import { ClipboardButton } from "@/components/ui/lumen/controls/clipboard-button";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `ClipboardButton` | — | The button. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/clipboard-button.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- copy button, copy to clipboard, copy code, copy link, copy API key, Lumen
- API keys, invite links, IDs, install commands: anything people paste somewhere else

### Not when
- Long code: Code Block has its own copy

## Mistakes
- Don't copy secrets the person can't already see on the page

## Usage

```tsx
"use client";

import { ClipboardButton } from "@/components/ui/lumen/controls/clipboard-button";

export function ApiKeyRow({ apiKey, inviteUrl }: { apiKey: string; inviteUrl: string }) {
  return (
    <div className="flex items-center gap-2">
      <ClipboardButton value={apiKey}>Copy key</ClipboardButton>
      {/* Icon-only: name it */}
      <ClipboardButton value={inviteUrl} aria-label="Copy invite link" />
      {/* Made on press (a fresh link) */}
      <ClipboardButton value={() => fetch("/api/invite").then((r) => r.text())}>Copy a new invite link</ClipboardButton>
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | As on Button: subtle is shorter on a calm curve; off is instant. Reduced motion is respected automatically. |
| `value` | `string \| (() => string \| Promise<string>)` |  | The text, or a function that makes it on press. |
| `children` | `ReactNode` |  | The label. Without it the button is icon-only: give it an aria-label. |
| `copiedLabel / errorLabel` | `string` | `"Copied" / "Couldn't copy"` | Shown and announced after a press. |
| `resetMs` | `number` | `1800` | How long the result shows. |
| `onCopy` | `(text: string) => void` |  | Called once the text is on the clipboard. |
| `variant / size` | `Button props` | `"secondary" / "default"` | As on Button. |

Full docs: https://beautiful-ui.dev/components/lumen-clipboard-button

## Customising
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Enter or Space | Copy |

## Motion inventory

| Interaction | What moves |
|---|---|
| Copied | The icon turns into a check that draws itself in, then returns |

## Accessibility contract (preserve when editing)
- The result is announced through a polite status
- Icon-only needs an aria-label; the result keeps it

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-clipboard-button.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-clipboard-button.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-clipboard-button`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/clipboard-button.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/clipboard-button.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/lumen/button";

/*
 * Clipboard Button: copies text and says so. The copy icon becomes a check that draws itself in, the label
 * reads "Copied" for a moment, and screen readers hear it. If the clipboard is blocked it falls back to
 * the older copy command, and only then shows "Couldn't copy". The value can be computed on press,
 * even asynchronously (a fresh invite link, a signed URL).
 *
 *   <ClipboardButton value={apiKey}>Copy key</ClipboardButton>
 *   <ClipboardButton value={() => api.createInviteLink()} aria-label="Copy invite link" />
 */

/** Copies text; the async Clipboard API first, then a hidden textarea for older or locked-down browsers. */
export async function copyText(text: string): Promise<void> {
  try {
    await navigator.clipboard.writeText(text);
    return;
  } catch {
    // The older copy command needs a selection; put focus back where it was afterwards.
    const before = document.activeElement as HTMLElement | null;
    const area = document.createElement("textarea");
    area.value = text;
    area.setAttribute("readonly", "");
    area.style.cssText = "position:fixed;inset-block-start:0;opacity:0;pointer-events:none";
    document.body.append(area);
    let ok = false;
    try {
      area.select();
      ok = document.execCommand("copy");
    } finally {
      area.remove();
      before?.focus({ preventScroll: true });
    }
    if (!ok) throw new Error("Copy was blocked");
  }
}

function ClipboardButton({
  value,
  children,
  copiedLabel = "Copied",
  errorLabel = "Couldn't copy",
  resetMs = 1800,
  onCopy,
  onClick,
  variant = "secondary",
  size = "default",
  motion,
  disabled,
  className,
  "aria-label": ariaLabel,
  ...props
}: Omit<React.ComponentProps<"button">, "value" | "children" | "onCopy"> & {
  /** The text, or a function that returns it (a promise works too). */
  value: string | (() => string | Promise<string>);
  /** The label. Without it the button is icon-only: give it an aria-label. */
  children?: React.ReactNode;
  copiedLabel?: string;
  errorLabel?: string;
  /** How long "Copied" shows. Default 1800ms. */
  resetMs?: number;
  /** Called with the text once it's on the clipboard. */
  onCopy?: ((text: string) => void) | undefined;
  variant?: "default" | "secondary" | "outline" | "ghost";
  size?: "xs" | "sm" | "default" | "lg";
  motion?: "full" | "subtle" | "off";
}) {
  const [state, setState] = React.useState<"idle" | "loading" | "success" | "error">("idle");
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const mounted = React.useRef(true);
  React.useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
      clearTimeout(timer.current);
    };
  }, []);
  const iconOnly = children == null;

  const copy = async (e: React.MouseEvent<HTMLElement>) => {
    // Your onClick runs first; preventDefault() in it skips the copy.
    onClick?.(e as React.MouseEvent<HTMLButtonElement>);
    if (e.defaultPrevented || state === "loading") return;
    clearTimeout(timer.current);
    setState("loading");
    let text: string;
    try {
      if (typeof value === "function" && typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
        // Safari only allows the write inside the click: start it now with a promised Blob, so a value
        // made asynchronously (a fresh invite link) still lands on the clipboard.
        const made = Promise.resolve(value());
        // Both promises get a handler at once, so a failure in either is never left unhandled.
        const wrote = navigator.clipboard
          .write([new ClipboardItem({ "text/plain": made.then((t) => new Blob([t], { type: "text/plain" })) })])
          .then(
            () => true,
            () => false,
          );
        text = await made;
        if (!(await wrote)) await copyText(text);
      } else {
        text = typeof value === "function" ? await value() : value;
        await copyText(text);
      }
    } catch {
      if (!mounted.current) return;
      setState("error");
      timer.current = setTimeout(() => setState("idle"), resetMs);
      return;
    }
    if (!mounted.current) return;
    setState("success");
    timer.current = setTimeout(() => setState("idle"), resetMs);
    // A throwing callback is the caller's bug, not a failed copy.
    try {
      onCopy?.(text);
    } catch (err) {
      console.error(err);
    }
  };

  const sizeName = iconOnly ? (size === "default" ? "icon" : (`icon-${size}` as const)) : size;
  const label = state === "success" ? copiedLabel : state === "error" ? errorLabel : children;
  return (
    <>
      <Button
        data-slot="clipboard-button"
        variant={variant}
        size={sizeName}
        icon="copy"
        state={state}
        motion={motion}
        disabled={disabled}
        aria-label={ariaLabel}
        onClick={copy}
        className={cn(`lumen-clipboard-button`, className)}
        {...props}
      >
        {iconOnly ? null : (
          // Every label stacked in one cell: the widest sets the width, so Copied never shifts the layout.
          <span className={`lumen-clipboard-label`}>
            <span aria-hidden>{children}</span>
            <span aria-hidden>{copiedLabel}</span>
            <span aria-hidden>{errorLabel}</span>
            <span key={state} data-shown="">{label}</span>
          </span>
        )}
      </Button>
      <span role="status" className={`lumen-sr`}>
        {state === "success" ? copiedLabel : state === "error" ? errorLabel : ""}
      </span>
    </>
  );
}

export { ClipboardButton };
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-clipboard-button {
    flex: none;
  }
  .lumen-clipboard-label {
    display: inline-grid;
  }
  .lumen-clipboard-label>span {
    grid-area: 1/1;
    text-align: center;
  }
  .lumen-clipboard-label>span[aria-hidden] {
    visibility: hidden;
  }
  .lumen-clipboard-label>span[data-shown] {
    animation: lumen-clip-fade calc(.12s * var(--gcp-k,1)) ease;
  }
  @keyframes lumen-clip-fade {
    from {
      opacity: 0;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
