Copy Button
A ghost button that copies text to the clipboard and cross-fades copy → check (or a failed mark), announced to screen readers; icon-only by default, or `show-label` for Copy → Copied text.
Preview
{{-- Icon-only (the accessible name is `label`), and `show-label` for Copy → Copied text. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex items-center gap-3 rounded-md border border-border bg-muted ps-4 pe-2 py-2 text-sm text-foreground">
<span class="flex-1 truncate font-mono">composer require jml/brok</span>
<x-ui.copy-button value="composer require jml/brok" label="Copy command" />
</div>
<div class="flex items-center gap-3 rounded-md border border-border bg-muted ps-4 pe-2 py-2 text-sm text-foreground">
<span class="flex-1 truncate font-mono">php artisan ui:add copy-button</span>
<x-ui.copy-button value="php artisan ui:add copy-button" show-label variant="outline" />
</div>
</div>
Installation
php artisan ui:add copy-button
Note
This component ships an Alpine behavior module at
resources/js/ui/copy-button.js. Import it once from your bundle so it registers on alpine:init:
import './copy-button.js';
Registry contract
php artisan ui:add copy-button
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/copy-button.blade.php -
resources/js/ui/copy-button.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Copy Button (`copy-button`)
A ghost button that copies text to the clipboard and cross-fades copy → check (or a failed mark), announced to screen readers; icon-only by default, or `show-label` for Copy → Copied text.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add copy-button
```
## Usage
```blade
{{-- Icon-only (the accessible name is `label`), and `show-label` for Copy → Copied text. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex items-center gap-3 rounded-md border border-border bg-muted ps-4 pe-2 py-2 text-sm text-foreground">
<span class="flex-1 truncate font-mono">composer require jml/brok</span>
<x-ui.copy-button value="composer require jml/brok" label="Copy command" />
</div>
<div class="flex items-center gap-3 rounded-md border border-border bg-muted ps-4 pe-2 py-2 text-sm text-foreground">
<span class="flex-1 truncate font-mono">php artisan ui:add copy-button</span>
<x-ui.copy-button value="php artisan ui:add copy-button" show-label variant="outline" />
</div>
</div>
```
## Props
- `value` (string, default ``) — The text written to the clipboard on click.
- `timeout` (int, default `2000`) — Milliseconds the copied (or failed) state stays shown before reverting to idle.
- `label` (string, default `Copy`) — Accessible name at rest (icon form), or visible text at rest when showLabel is true.
- `copiedLabel` (string, default `Copied`) — Announced by the live region, and shown in place of label in the labelled form, after a successful copy.
- `failedLabel` (string, default `Copy failed`) — Announced and shown when the clipboard write is refused.
- `showLabel` (bool, default `false`) — Shows the label text beside the icon instead of the icon-only form.
- `variant` (ghost|outline|secondary|default|link|destructive, default `ghost`) — Forwarded to the composed button; any button variant.
- `size` (sm|md|lg, default `sm`) — Forwarded to the composed button; any button size.
- `shape` (string, default `default`) — Forwarded to the composed button; any button shape.
## Use when
- Use for explicit actions and commits; keep one action visually primary when several sit together.
- Letting the user copy a code snippet, token or command to the clipboard in one click.
- Pairing a compact icon-only trigger beside a value the user is likely to paste elsewhere.
## Avoid when
- Do not use button treatment for navigation-only view changes when links, tabs, or radios describe state more clearly.
- The value is already editable and visible in a field with its own copy affordance; use input-copy instead.
- The action does more than write to the clipboard; use a plain button instead.
## Anti-patterns
- Using a button for navigation-only state changes
- Presenting multiple equally dominant primary actions
## Rules
- Use the `<brok:copy-button>` tag (or `<x-ui.copy-button>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/copy-button
- Registry JSON (files, props, contract): https://brokui.dev/r/open/copy-button.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | The text written to the clipboard on click. | |
| timeout | int | 2000 | Milliseconds the copied (or failed) state stays shown before reverting to idle. |
| label | string | Copy | Accessible name at rest (icon form), or visible text at rest when showLabel is true. |
| copiedLabel | string | Copied | Announced by the live region, and shown in place of label in the labelled form, after a successful copy. |
| failedLabel | string | Copy failed | Announced and shown when the clipboard write is refused. |
| showLabel | bool | false | Shows the label text beside the icon instead of the icon-only form. |
| variant | ghost | outline | secondary | default | link | destructive | ghost | Forwarded to the composed button; any button variant. |
| size | sm | md | lg | sm | Forwarded to the composed button; any button size. |
| shape | string | default | Forwarded to the composed button; any button shape. |
Slots
default— Unused; the icon and label are rendered from props.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Writes value to the clipboard on click and cross-fades the icon from copy to a check mark.
- Reverts to the idle icon automatically after timeout milliseconds.
- Falls back to a failed state (and failedLabel) when the clipboard write is refused, such as in an insecure context or without permission.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Commit an explicit user action.
Use when
- Use for explicit actions and commits; keep one action visually primary when several sit together.
- Letting the user copy a code snippet, token or command to the clipboard in one click.
- Pairing a compact icon-only trigger beside a value the user is likely to paste elsewhere.
Avoid when
- Do not use button treatment for navigation-only view changes when links, tabs, or radios describe state more clearly.
- The value is already editable and visible in a field with its own copy affordance; use input-copy instead.
- The action does more than write to the clipboard; use a plain button instead.
Use instead
- Link for navigation
- Tabs or radios for view changes
Anti-patterns
- Using a button for navigation-only state changes
- Presenting multiple equally dominant primary actions
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
managed
- The accessible name stays constant across states; the state change is announced separately from a polite live region.
- The icon inside the button is aria-hidden so screen readers read only the button's accessible name and the live-region announcement.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
{{-- Icon-only (the accessible name is `label`), and `show-label` for Copy → Copied text. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<div class="flex items-center gap-3 rounded-md border border-border bg-muted ps-4 pe-2 py-2 text-sm text-foreground">
<span class="flex-1 truncate font-mono">composer require jml/brok</span>
<x-ui.copy-button value="composer require jml/brok" label="Copy command" />
</div>
<div class="flex items-center gap-3 rounded-md border border-border bg-muted ps-4 pe-2 py-2 text-sm text-foreground">
<span class="flex-1 truncate font-mono">php artisan ui:add copy-button</span>
<x-ui.copy-button value="php artisan ui:add copy-button" show-label variant="outline" />
</div>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'value' => '',
'timeout' => 2000,
// Accessible name (icon form) or visible text (`show-label`) at rest.
'label' => 'Copy',
// Announced by the live region, and shown in place of `label` in the labelled form.
'copiedLabel' => 'Copied',
// Announced (and shown) when the clipboard write is refused: insecure context,
// permission denied, or the document not focused.
'failedLabel' => 'Copy failed',
// Set true for "Copy" + icon that becomes "Copied" + check; false is the icon-only form.
'showLabel' => false,
// Passed to the composed button: any button variant/size/shape.
'variant' => 'ghost',
'size' => 'sm',
'shape' => 'default',
])
@php
// Accept a string, a backed enum or a Stringable for `variant`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
// Explicit vocabulary, forwarded to the composed button; an unknown variant,
// size or shape falls back to the default instead of leaking through.
$variants = ['ghost', 'outline', 'secondary', 'default', 'link', 'destructive'];
$sizes = ['sm', 'md', 'lg'];
$shapes = ['default', 'pill'];
$variant = in_array($variant, $variants, true) ? $variant : 'ghost';
$size = in_array($size, $sizes, true) ? $size : 'sm';
$shape = in_array($shape, $shapes, true) ? $shape : 'default';
@endphp
{{--
Copy button — composes <x-ui.button>. The three states (copy / copied /
failed) are grid-stacked icons that cross-fade, so the control never changes
size. The accessible name stays constant; the state change is announced from
the sibling live region, because the icon form's slot is aria-hidden inside
the button. Consumer attributes land on the button itself.
--}}
<span
data-slot="copy-button"
x-data="uiCopyButton({ value: @js($value), timeout: @js((int) $timeout) })"
x-bind:data-state="copied ? 'copied' : (failed ? 'failed' : 'idle')"
class="contents"
>
<span role="status" aria-live="polite" class="sr-only" x-text="copied ? @js(__($copiedLabel)) : (failed ? @js(__($failedLabel)) : '')"></span>
<x-ui.button
:variant="$variant"
:size="$size"
:shape="$shape"
:icon-label="$showLabel ? null : $label"
@click="copy()"
::class="failed ? 'text-destructive hover:text-destructive' : ''"
{{ $attributes->merge(['class' => $showLabel ? '' : 'text-muted-foreground hover:text-foreground']) }}
>
@php
// One stack of three grid-stacked marks, shared by both forms.
$copyIcons = <<<'HTML'
<span class="inline-grid size-4 shrink-0 place-items-center" aria-hidden="true">
<svg
class="col-start-1 row-start-1 size-4 motion-safe:transition motion-safe:duration-fast motion-safe:ease-standard motion-reduce:transition-none"
:class="copied || failed ? 'opacity-0 motion-safe:scale-75' : 'opacity-100 motion-safe:scale-100'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg
class="col-start-1 row-start-1 size-4 text-success motion-safe:transition motion-safe:duration-fast motion-safe:ease-standard motion-reduce:transition-none"
:class="copied ? 'opacity-100 motion-safe:scale-100' : 'opacity-0 motion-safe:scale-75'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><polyline points="20 6 9 17 4 12"/></svg>
<svg
class="col-start-1 row-start-1 size-4 motion-safe:transition motion-safe:duration-fast motion-safe:ease-standard motion-reduce:transition-none"
:class="failed ? 'opacity-100 motion-safe:scale-100' : 'opacity-0 motion-safe:scale-75'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12"/></svg>
</span>
HTML;
@endphp
@if ($showLabel)
{{-- The marks ride in the button's icon slot so they centre beside the text. --}}
<x-slot:icon>{!! $copyIcons !!}</x-slot:icon>
<span data-slot="copy-button-label" x-text="copied ? @js(__($copiedLabel)) : (failed ? @js(__($failedLabel)) : @js(__($label)))">{{ __($label) }}</span>
@else
{!! $copyIcons !!}
@endif
</x-ui.button>
</span>
/**
* Copy button behavior.
*
* Copies a given text value (or the inner text of a referenced element) to the
* clipboard, then toggles a "copied" state for a configurable timeout so the UI
* can animate a copy → check icon transition and announce success to screen readers.
* A refused write (insecure context, permission denied, document not focused)
* sets "failed" for the same timeout instead of claiming the clipboard has the text.
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiCopyButton', (config = {}) => ({
copied: false,
failed: false,
value: config.value ?? '',
timeout: config.timeout ?? 2000,
_t: null,
async copy() {
const text =
this.value ||
(this.$root.dataset.copyTarget
? (document.querySelector(this.$root.dataset.copyTarget)?.innerText ?? '')
: '');
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers / insecure contexts.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
this.copied = true;
this.failed = false;
} catch (e) {
this.copied = false;
this.failed = true;
}
clearTimeout(this._t);
this._t = setTimeout(() => {
this.copied = false;
this.failed = false;
}, this.timeout);
},
destroy() {
clearTimeout(this._t);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0