Skip to content

cv-copy-button

Button that copies a value to the system clipboard with three-state visual feedback (idle, success, error).

Headless: createCopyButton

Usage

View source
html
<div class="copy-button-demo-shell" data-demo="copy-button" data-live-demo-height="420">
  <section class="copy-button-demo-hero" aria-labelledby="copy-button-demo-title">
    <div class="copy-button-demo-copy">
      <p class="copy-button-demo-kicker">Clipboard boundary</p>
      <h3 id="copy-button-demo-title">Copy sensitive values without putting them in markup</h3>
      <p>
        Values are assigned as properties, clipboard writes can be adapter-backed, and the visible control
        stays a compact icon with success/error feedback.
      </p>
    </div>

    <dl class="copy-button-demo-metrics" aria-label="Copy button contract highlights">
      <div>
        <dt>Value</dt>
        <dd>property-only</dd>
      </div>
      <div>
        <dt>Feedback</dt>
        <dd>idle / success / error</dd>
      </div>
      <div>
        <dt>Keyboard</dt>
        <dd>Enter and Space</dd>
      </div>
    </dl>
  </section>

  <section class="copy-button-demo-workbench" aria-label="Vault record copy actions">
    <div class="copy-button-demo-record">
      <div class="copy-button-demo-record-head">
        <div>
          <span>Vault record</span>
          <strong>border-relay.admin</strong>
        </div>
        <span class="copy-button-demo-badge">local adapter</span>
      </div>

      <div class="copy-button-demo-row" data-copy-row>
        <div>
          <span>Username</span>
          <code>alex@chromvoid.local</code>
        </div>
        <cv-copy-button
          data-copy-target="username"
          data-copy-label="Username"
          aria-label="Copy username"
          success-label="Username copied"
          error-label="Username copy failed"
          feedback-duration="5600"
        ></cv-copy-button>
      </div>

      <div class="copy-button-demo-row copy-button-demo-row--secret" data-copy-row>
        <div>
          <span>Password</span>
          <code>cv-••••-••••-91f3</code>
        </div>
        <cv-copy-button
          data-copy-target="password"
          data-copy-label="Password"
          aria-label="Copy password"
          success-label="Password copied"
          error-label="Password copy failed"
          feedback-duration="5600"
        ></cv-copy-button>
      </div>

      <div class="copy-button-demo-row" data-copy-row>
        <div>
          <span>TOTP</span>
          <code>493 120</code>
        </div>
        <cv-copy-button
          data-copy-target="totp"
          data-copy-label="TOTP"
          aria-label="Copy TOTP code"
          success-label="TOTP copied"
          error-label="TOTP copy failed"
          size="small"
          feedback-duration="5600"
        ></cv-copy-button>
      </div>

      <div class="copy-button-demo-row copy-button-demo-row--danger" data-copy-row>
        <div>
          <span>Policy probe</span>
          <code>denied by adapter</code>
        </div>
        <cv-copy-button
          data-copy-target="blocked"
          data-copy-label="Policy probe"
          aria-label="Copy blocked value"
          success-label="Blocked value copied"
          error-label="Clipboard blocked"
          feedback-duration="5600"
        ></cv-copy-button>
      </div>
    </div>

    <aside class="copy-button-demo-side" aria-label="Copy button variants and event output">
      <div class="copy-button-demo-variants">
        <div>
          <cv-copy-button
            data-copy-target="plain"
            data-copy-label="Plain button"
            aria-label="Copy plain value"
            appearance="plain"
            success-label="Plain copied"
            feedback-duration="5600"
          ></cv-copy-button>
          <span>plain</span>
        </div>
        <div>
          <cv-copy-button
            data-copy-target="large"
            data-copy-label="Large button"
            aria-label="Copy large value"
            size="large"
            success-label="Large copied"
            feedback-duration="5600"
          ></cv-copy-button>
          <span>large</span>
        </div>
        <div>
          <cv-copy-button aria-label="Copy disabled value" disabled></cv-copy-button>
          <span>disabled</span>
        </div>
      </div>

      <p class="copy-button-demo-log" role="status" aria-live="polite" data-copy-output>
        Waiting for a copy event. Click any active copy button.
      </p>
    </aside>
  </section>
</div>

<script type="module">
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

  document
    .querySelectorAll('.copy-button-demo-shell[data-demo="copy-button"]:not([data-ready])')
    .forEach((shell) => {
      shell.dataset.ready = 'true'

      const output = shell.querySelector('[data-copy-output]')
      const rows = [...shell.querySelectorAll('[data-copy-row]')]

      const setOutput = (message, state) => {
        shell.dataset.copyState = state
        if (output) output.textContent = message
      }

      const markRow = (button, state) => {
        rows.forEach((row) => delete row.dataset.copyResult)
        button?.closest('[data-copy-row]')?.setAttribute('data-copy-result', state)
      }

      const writeLocally = async (text) => {
        await delay(120)
        shell.dataset.clipboard = text
      }

      const setupButton = (target, value, writeText = writeLocally) => {
        const button = shell.querySelector(`[data-copy-target="${target}"]`)
        if (!button) return

        button.value = value
        button.clipboard = {writeText}
      }

      setupButton('username', 'alex@chromvoid.local')
      setupButton('password', async () => {
        await delay(180)
        return 'cv-02e6-4d89-91f3'
      })
      setupButton('totp', '493120')
      setupButton('plain', 'shadow-tag:decoy-visible')
      setupButton('large', 'recovery-window:18m')
      setupButton('blocked', 'policy-blocked-note', async () => {
        await delay(120)
        throw new Error('Clipboard blocked by adapter policy')
      })

      shell.addEventListener('cv-copy', (event) => {
        const button = event.target instanceof HTMLElement ? event.target : null
        const label = button?.dataset.copyLabel ?? 'Value'
        const value = String(event.detail.value)
        markRow(button, 'success')
        setOutput(`${label}: copied ${value.length} characters through the injected adapter.`, 'success')
      })

      shell.addEventListener('cv-error', (event) => {
        const button = event.target instanceof HTMLElement ? event.target : null
        const label = button?.dataset.copyLabel ?? 'Value'
        const message = event.detail.error?.message ?? 'Copy failed'
        markRow(button, 'error')
        setOutput(`${label}: ${message}.`, 'error')
      })

      const activate = (target) => {
        const button = shell.querySelector(`[data-copy-target="${target}"]`)
        const base = button?.shadowRoot?.querySelector('[part="base"]')
        base?.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}))
      }

      window.setTimeout(() => activate('password'), 260)
      window.setTimeout(() => activate('blocked'), 920)
    })
</script>

Anatomy

<cv-copy-button> (host)
└── <div part="base" role="button">
    ├── <span part="copy-icon">
    │   └── <slot name="copy-icon"> (default: clipboard icon)
    ├── <span part="success-icon">
    │   └── <slot name="success-icon"> (default: check icon)
    ├── <span part="error-icon">
    │   └── <slot name="error-icon"> (default: x icon)
    └── <span part="status" role="status" aria-live="polite">

Attributes

AttributeTypeDefaultDescription
disabledBooleanfalsePrevents interaction
feedback-durationNumber1500Milliseconds to show success/error feedback before reverting to idle
sizeString"medium"Size: small | medium | large
appearanceString"default"Appearance: default | plain
success-labelString"Copied"Text used for success aria-label and live-region feedback
error-labelString"Copy failed"Text used for error aria-label and live-region feedback
aria-labelStringunsetAccessible label used while idle

Property-only options:

PropertyTypeDescription
valuestring | (() => Promise<string>)Text or async getter to copy; never reflected as an attribute
clipboard{ writeText(text: string): Promise<void> }Injectable clipboard adapter for app-specific clipboard policies such as domain auto-wipe

Sizes

Size--cv-copy-button-size
small30px
medium36px
large42px

Slots

SlotDescription
copy-iconIcon shown in idle state (default: clipboard icon)
success-iconIcon shown after successful copy (default: check icon)
error-iconIcon shown after copy failure (default: x icon)

CSS Parts

PartElementDescription
base<div>Root interactive element with role="button"
copy-icon<span>Wrapper around the copy-icon slot
success-icon<span>Wrapper around the success-icon slot
error-icon<span>Wrapper around the error-icon slot
status<span>Live region for assistive technology announcements

CSS Custom Properties

PropertyDefaultDescription
--cv-copy-button-size36pxOverall button size (width and height)
--cv-copy-button-border-radiusvar(--cv-radius-sm, 6px)Border radius for button shape
--cv-copy-button-success-colorvar(--cv-color-success, #4ade80)Color applied during success state
--cv-copy-button-error-colorvar(--cv-color-danger, #ff7d86)Color applied during error state
--cv-copy-button-backgroundvar(--cv-color-surface)Base background
--cv-copy-button-border-colorvar(--cv-color-border)Base border color
--cv-copy-button-colorvar(--cv-color-text)Base text/icon color
--cv-copy-button-hover-background--cv-copy-button-backgroundHover background
--cv-copy-button-hover-border-colorvar(--cv-color-primary)Hover border color
--cv-copy-button-hover-color--cv-copy-button-colorHover text/icon color
--cv-copy-button-plain-backgroundtransparentPlain appearance background
--cv-copy-button-plain-border-colortransparentPlain appearance border
--cv-copy-button-plain-hover-background--cv-copy-button-hover-backgroundPlain hover background
--cv-copy-button-plain-hover-border-color--cv-copy-button-plain-border-colorPlain hover border
--cv-copy-button-plain-hover-color--cv-copy-button-hover-colorPlain hover text/icon color

Additionally, component styles depend on theme tokens through fallback values:

Theme PropertyDefaultDescription
--cv-color-border#2a3245Base border color
--cv-color-surface#141923Surface background color
--cv-color-text#e8ecf6Default text/icon color
--cv-color-success#4ade80Success accent color
--cv-color-danger#ff7d86Danger accent color
--cv-duration-fast120msTransition duration
--cv-easing-standardeaseTransition timing function
--cv-radius-sm6pxBase radius fallback

Visual States

Host selectorDescription
:host([disabled])Reduced opacity (0.55), cursor: not-allowed
:host([status="idle"])Default state; copy icon visible, success/error icons hidden
:host([status="success"])Success color applied via --cv-copy-button-success-color; success icon visible
:host([status="error"])Error color applied via --cv-copy-button-error-color; error icon visible
:host([copying])Shown while async copy is in-flight; cursor: progress
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides

Reactive State Mapping

cv-copy-button is a visual adapter over headless createCopyButton.

UIKit properties to headless actions

UIKit PropertyDirectionHeadless Binding
disabledattr -> actionactions.setDisabled(value)
feedback-durationattr -> actionactions.setFeedbackDuration(value)
valueprop -> actionactions.setValue(value)
clipboardprop -> model optionRecreates the model with the injected adapter
aria-labelattr -> model optionRecreates the model with the idle label
success-labelattr -> model optionRecreates the model with localized success feedback
error-labelattr -> model optionRecreates the model with localized error feedback

Headless state to DOM reflection

Headless StateDirectionDOM Reflection
state.status()state -> attr[status] host attribute ("idle" | "success" | "error")
state.isDisabled()state -> attr[disabled] host attribute
state.isCopying()state -> attr[copying] host attribute

Headless contracts to DOM elements

ContractTarget ElementNotes
contracts.getButtonProps()Inner [part="base"]Spread as attributes; provides role, aria-disabled, tabindex, aria-label, onClick, onKeyDown, onKeyUp
contracts.getStatusProps()Inner [part="status"]Spread as attributes; provides role="status", aria-live="polite", aria-atomic="true"
contracts.getIconContainerProps('copy')Inner [part="copy-icon"]Spread as attributes; provides aria-hidden, hidden
contracts.getIconContainerProps('success')Inner [part="success-icon"]Spread as attributes; provides aria-hidden, hidden
contracts.getIconContainerProps('error')Inner [part="error-icon"]Spread as attributes; provides aria-hidden, hidden

Headless options passed from UIKit API

UIKit APIHeadless OptionNotes
valuevalueProperty-only; accepts string | (() => Promise<string>)
feedback-durationfeedbackDurationNumeric attribute, defaults to 1500
disabledisDisabledBoolean attribute
aria-labelariaLabelStandard ARIA labeling
success-labelsuccessLabelLocalized success feedback label
error-labelerrorLabelLocalized error feedback label
clipboardclipboardProperty-only injectable clipboard adapter

UIKit-only concerns (not in headless)

  • Icon rendering via slotted content (copy-icon, success-icon, error-icon slots with default SVG icons)
  • CSS custom properties for sizing and colors (--cv-copy-button-*)
  • size attribute controlling icon/button dimensions
  • appearance attribute controlling default or plain visual treatment
  • cv-copy and cv-error custom events dispatched on the host element
  • Pulse/scale animation on copy activation

Headless-owned concerns (UIKit does NOT reimplement)

  • Copy cycle logic (resolve value, write to clipboard, transition status, schedule revert)
  • Keyboard interaction (Enter on keydown, Space on keyup)
  • Click handling
  • ARIA attribute computation (aria-disabled, tabindex, aria-label)
  • Timer management (revert timer, cancellation)
  • isCopying re-entrant guard

Events

EventDetailDescription
cv-copy{ value: string }Fired on successful clipboard write
cv-error{ error: unknown }Fired on clipboard write failure or async value getter failure

Events are dispatched by the UIKit adapter by providing onCopy and onError callbacks to createCopyButton:

  • onCopy(value) -> dispatches cv-copy with { detail: { value } }
  • onError(error) -> dispatches cv-error with { detail: { error } }

ChromVoid UIKit documentation