Skip to content

Copy Button

View as Markdown

Copy a value to the clipboard, and say so. Custom element

Copy Button
Copy this markup
<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

Styles and registration
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:

Explicit registration
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.

PartRequiredSelectorPurpose
triggerYes[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.
idleNo[data-ui-part~='idle']Shown while nothing has been copied. Decorative — hide it from assistive technology with aria-hidden="true".
copiedNo[data-ui-part~='copied']Shown briefly after a successful copy. Decorative in the same way, and its text is the default announcement.
statusNo[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.

AttributeValuesDefaultDescription
valueany stringThe 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.
fromany stringId 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-durationany number1800Milliseconds 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-messageany stringWhat 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>

PropertyTypeNotes
valuestringReflects the value attribute, documented above.
fromstringReflects the from attribute, documented above.
feedbackDurationstringReflects the feedback-duration attribute, documented above.
copiedMessagestringReflects the copied-message attribute, documented above.
EventDetailDescription
ui-before-copyCustomEvent<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-copyCustomEvent<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.

StateSourceHow to express it
--copiedcustom-stateSet 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.