Copy Button
View as MarkdownCopy a value to the clipboard, and say so. Custom element
<code class="ui-code" id="install-command">pnpm add @timelessui/components</code><ui-copy-button from="install-command" copied-message="Install command copied"> <button class="ui-button" data-ui-variant="secondary" data-ui-part="trigger" type="button" aria-label="Copy the install command"> <span data-ui-part="idle" aria-hidden="true">Copy</span> <span data-ui-part="copied" aria-hidden="true">Copied</span> </button> <span data-ui-part="status" role="status"></span></ui-copy-button>Install
import '@timelessui/components/css/tokens.css'import '@timelessui/components/css/core/button.css'import '@timelessui/components/css/core/code.css'import '@timelessui/components/css/core/copy-button.css'import '@timelessui/components/css/themes/atmosphere/tokens.css'import '@timelessui/components/css/themes/atmosphere/button.css'import '@timelessui/components/css/themes/atmosphere/code.css'import '@timelessui/components/css/themes/atmosphere/copy-button.css'import '@timelessui/components/register/ui-copy-button'A register/ import is a side effect: it defines the element as the module evaluates, so it has to run in code the browser loads. To register explicitly instead — to control the timing, or to define into another window — call the function from the matching define/ entry point, which registers nothing on its own:
import { defineCopyButtonElement } from '@timelessui/components/define/ui-copy-button'defineCopyButtonElement()Anatomy
Parts are authored in your own markup and identified by the selector below, so your CSS can target the same anatomy without loading Timeless styles. Required parts must be present for the component to work. Private data-ui-internal-* hooks are written by the runtime and must never be authored.
| Part | Required | Selector | Purpose |
|---|---|---|---|
trigger | Yes | [data-ui-part~='trigger'] | Native button that copies. Author it as <button type="button"> with an accessible name; the name must not change on copy, because a button renamed while it holds focus is announced inconsistently across screen readers. |
idle | No | [data-ui-part~='idle'] | Shown while nothing has been copied. Decorative — hide it from assistive technology with aria-hidden="true". |
copied | No | [data-ui-part~='copied'] | Shown briefly after a successful copy. Decorative in the same way, and its text is the default announcement. |
status | No | [data-ui-part~='status'] | A role="status" region the confirmation is written into. Author it — Timeless writes text and never creates the element — and without it nothing is announced. It is clipped away by default, because the visible confirmation is the copied part; restyle it if you want it seen. |
Attributes
Every value below is implemented by the stylesheets this component ships. Boolean attributes are presence-based: author the attribute with no value, or omit it.
| Attribute | Values | Default | Description |
|---|---|---|---|
value | any string | — | The literal text to copy. Wins over from on presence rather than content, the way value does on an option: an explicit value="" is the author saying to copy nothing. |
from | any string | — | Id of the element to read instead of value. An input, textarea, or select gives its current value; anything else gives its text. Read at activation rather than cached, so this is the one to reach for when the text is long or changes: it stays current on its own, and nothing is duplicated into an attribute. Assigning the value property works too, but a long string then reflects into the DOM. |
feedback-duration | any number | 1800 | Milliseconds the --copied state persists after a successful copy, and with it the text in the status region — the two clear together, so copying the same value twice is announced twice. 0 clears both on the next task, which is short enough that a screen reader may miss the announcement. |
copied-message | any string | — | What the status region announces after a successful copy. Falls back to the copied part’s text, so a button whose confirmation is a word needs no message at all and an icon-only one does. With neither, nothing is announced. |
Element API
Attributes above are the authoring surface. These are the DOM properties and events the registered element adds once it upgrades.
<ui-copy-button>
| Property | Type | Notes |
|---|---|---|
value | string | Reflects the value attribute, documented above. |
from | string | Reflects the from attribute, documented above. |
feedbackDuration | string | Reflects the feedback-duration attribute, documented above. |
copiedMessage | string | Reflects the copied-message attribute, documented above. |
| Event | Detail | Description |
|---|---|---|
ui-before-copy | CustomEvent<CopyProposalDetail> | Cancelable proposal dispatched before anything is written, carrying the resolved value. Call preventDefault() to reject the copy, and no ui-copy follows. Or call detail.respondWith(promise) to perform the write yourself — which is how you copy an image, a blob, or text/html, since writeText carries a string and nothing else. The element then awaits your promise and drives --copied, the announcement, and ui-copy from its outcome, so a confirmation never claims a copy that did not happen. Call it synchronously, and call your own clipboard method synchronously too — the click’s transient user activation is the same one the element depends on. ClipboardItem accepts a promised blob, so new ClipboardItem({ 'image/png': blobPromise }) starts the write immediately while the data resolves. |
ui-copy | CustomEvent<CopyDetail> | Dispatched once per activation, on success and on every failure, unless a listener cancelled the proposal. The detail carries status, the resolved value, and a reason naming what went wrong: empty when nothing resolved, unsupported when there is no Clipboard API, denied when the browser refused the write, and rejected when a respondWith promise failed. |
State
Native attributes, ARIA, and platform pseudo-classes are authoritative. Style state through the selectors below rather than adding your own state classes.
| State | Source | How to express it |
|---|---|---|
--copied | custom-state | Set for feedback-duration after a successful copy. Public: style ui-copy-button:state(--copied) yourself. |
Styling
Root identity: ui-copy-button. Required stylesheets: tokens.css, core/button.css, core/code.css, core/copy-button.css, themes/atmosphere/tokens.css, themes/atmosphere/button.css, themes/atmosphere/code.css, themes/atmosphere/copy-button.css.
This component adds no custom properties of its own. Restyle it through the design tokens or your own CSS.
Design tokens this component reads (26)
These are global Atmosphere tokens. Change them once at the theme level rather than per component.
--ui-accent · --ui-bg-accent · --ui-bg-accent-active · --ui-bg-accent-hover · --ui-bg-control · --ui-bg-control-active · --ui-bg-control-hover · --ui-bg-control-muted · --ui-bg-danger · --ui-bg-danger-active · --ui-bg-danger-hover · --ui-bg-surface · --ui-duration-fast · --ui-ease-standard · --ui-fg · --ui-focus · --ui-font-mono · --ui-line · --ui-radius-control · --ui-radius-sm · --ui-shadow-control-accent · --ui-shadow-control-active · --ui-shadow-control-danger · --ui-shadow-outline-control · --ui-space-2 · --ui-space-4
Accessibility
Keep the native elements, roles, and relationships shown in the example. Timeless adds only the state and keyboard coordination the platform does not already provide, and it never supplies your accessible names — those depend on your content.
Follows the Button pattern from the ARIA Authoring Practices Guide.
The trigger is a native button, so activation, focus, and the accessible name are the platform’s. Timeless adds only what the platform cannot: the idle/copied swap is decorative and stays hidden from assistive technology, and the confirmation reaches a screen reader through the authored status region rather than by renaming the button. Copying needs navigator.clipboard, which is absent outside a secure context; a trigger authored hidden is revealed only once the API is there, so a control that cannot work is never shown.
Before JavaScript runs
Copying needs navigator.clipboard, and the platform offers no declarative equivalent — so unlike most enhanced components, the markup above is not usable on its own. Author the trigger hidden and registration reveals it once the API is there, which is the only way to guarantee no dead control renders. A trigger you leave visible renders before registration and does nothing.
More examples
Open Copy Button in StoryLite for variants, controls, and manual testing.