shshadcn-htmx

Components

Copy Button

A native <button> that writes a string — a snippet, an API key, a URL — to the clipboard with the Async Clipboard API, then flips to a transient Copied state announced through an aria-live region. Progressive-enhancement fallback for non-secure contexts.

Installation

One file per stack — no npm package, no build step required. The click-to-copy behaviour is shared in site.js, scoped to [data-slot="copy-button"].

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/copy-button.json

2. Use it

components/ui/copy-button.tsx
import { CopyButton } from "@/components/ui/copy-button"

<CopyButton value="npm i shadcn-htmx" />
Or copy the source manually
components/ui/copy-button.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Copy Button — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Click-to-copy button: writes an associated string (a snippet, an API key, a
// URL) to the system clipboard, then flips to a transient "Copied" state and
// announces it through a visually-hidden aria-live region. The docs site's own
// code-block is a consumer.
//
// Built on the Async Clipboard API. navigator.clipboard.writeText(text)
// returns a Promise that resolves once the system clipboard has been updated;
// it works only in a secure context (HTTPS / localhost) and from a window that
// has focus:
//   repos/mdn/files/en-us/web/api/clipboard/writetext/index.md
// The shared behaviour in site.js follows web.dev's progressive-enhancement
// recipe — use the async API when present, otherwise fall back to a throwaway
// <textarea> + document.execCommand('copy'):
//   repos/web.dev/src/site/content/en/patterns/clipboard/copy-text/index.md
//
// Accessibility:
//   - A native <button> gives us role=button + Space/Enter activation for free
//     (APG button pattern: repos/aria-practices/content/patterns/button/).
//   - The transition is announced through an EMPTY element carrying
//     aria-live="polite": site.js writes "Copied" into it on success. MDN: the
//     aria-live attribute is set on an empty element that is then populated, so
//     AT announces the change without moving focus —
//     repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/index.md
//   - This is NOT a toggle: aria-pressed is wrong here (the label/state is
//     transient feedback, not a sticky on/off), so we use aria-live instead.
//
// All hx-*, data-* and aria-* attributes are forwarded onto the <button> via
// {...rest}, so the button can also trigger an htmx request if you want one.

export type CopyButtonVariant = "outline" | "ghost" | "secondary"
export type CopyButtonSize = "default" | "sm" | "icon"

const base =
  "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "disabled:pointer-events-none disabled:opacity-50 " +
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 " +
  // Transient success state. site.js sets data-copied="true" for a beat, then
  // clears it; we swap the copy glyph for the check and tint it success-green.
  "[&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 " +
  "[&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex"

const variants: Record<CopyButtonVariant, string> = {
  outline:
    "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
}

const sizes: Record<CopyButtonSize, string> = {
  default: "h-8 px-2.5",
  sm: "h-7 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
  icon: "size-8 [&]:px-0",
}

type CopyButtonProps = {
  // The string written to the clipboard. Either pass `value` directly, or
  // point `copyTarget` at the id of an element whose text/value to copy
  // (so the docs code-block can be a consumer without duplicating its text).
  value?: string
  copyTarget?: string
  variant?: CopyButtonVariant
  size?: CopyButtonSize
  // Visible label next to the icon (default "Copy" / "Copied"). For size
  // "icon" the label is dropped and the accessible name comes from ariaLabel.
  label?: string
  copiedLabel?: string
  // Accessible name. Required for size="icon"; otherwise the visible label
  // supplies the name.
  ariaLabel?: string
  // Politeness of the success announcement. polite waits for a graceful
  // pause; assertive interrupts. Default polite (MDN aria-live).
  live?: "polite" | "assertive"
  disabled?: boolean
  class?: ClassValue
  id?: string
  // Forward arbitrary attributes (hx-*, data-*, aria-*, name/value/form, …).
  [key: string]: any
}

const CopyIcon = () => (
  <svg
    data-copy-icon
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
  >
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
    <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
  </svg>
)

const CheckIcon = () => (
  <svg
    data-copy-check
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
  >
    <path d="M20 6 9 17l-5-5" />
  </svg>
)

export function CopyButton(props: CopyButtonProps) {
  const {
    value,
    copyTarget,
    variant = "outline",
    size = "default",
    label = "Copy",
    copiedLabel = "Copied",
    ariaLabel,
    live = "polite",
    disabled,
    class: className,
    id,
    ...rest
  } = props

  const iconOnly = size === "icon"
  const classes = cn(base, variants[variant], sizes[size], className)

  return (
    <button
      type="button"
      id={id}
      data-slot="copy-button"
      data-variant={variant}
      data-size={size}
      data-copy-text={value}
      data-copy-target={copyTarget}
      data-copied-label={copiedLabel}
      disabled={disabled}
      aria-label={ariaLabel ?? (iconOnly ? label : undefined)}
      class={classes}
      {...rest}
    >
      <CopyIcon />
      <CheckIcon />
      {!iconOnly && (
        <span data-copy-label>{label}</span>
      )}
      {/* Empty aria-live region — site.js writes "Copied" here on success so
          AT announces it without moving focus. MDN: aria-live on an empty
          element that is then populated. */}
      <span class="sr-only" aria-live={live} data-copy-status></span>
    </button>
  )
}

1. Save the file

Copy copy-button.html into templates/components/.

2. Use it

templates/components/copy-button.html
{% from "components/copy-button.html" import copy_button %}

{{ copy_button("npm i shadcn-htmx") }}
View source
templates/components/copy-button.html
{# Copy Button macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/copy-button.tsx so a Python/Flask/FastAPI/Django project
   renders the same markup our docs site renders.

   Click-to-copy: writes `value` (or the text of the element named by
   copy_target) to the clipboard via the Async Clipboard API, then flips to a
   transient "Copied" state announced through an empty aria-live region.

   Built on navigator.clipboard.writeText() with a progressive-enhancement
   fallback — see the shared behaviour shipped in site.js. Sources:
     repos/mdn/files/en-us/web/api/clipboard/writetext/index.md
     repos/web.dev/src/site/content/en/patterns/clipboard/copy-text/index.md
     repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/index.md

   Usage:
       {% from "components/copy-button.html" import copy_button %}
       {{ copy_button("npm i shadcn-htmx") }}
       {{ copy_button(copy_target="api-key", size="icon", aria_label="Copy API key") }}

   All hx-* attributes pass through via **attrs (underscores become dashes). #}

{% macro copy_button(
    value=none,
    copy_target=none,
    variant="outline",
    size="default",
    label="Copy",
    copied_label="Copied",
    aria_label=none,
    live="polite",
    disabled=false,
    extra_class="",
    **attrs
) %}
{%- set base -%}
inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 [&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex
{%- endset -%}

{%- set variants = {
    "outline": "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
    "ghost": "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
    "secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80"
} -%}

{%- set sizes = {
    "default": "h-8 px-2.5",
    "sm": "h-7 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
    "icon": "size-8 [&]:px-0"
} -%}

{%- set icon_only = (size == "icon") -%}

<button type="button"
        data-slot="copy-button" data-variant="{{ variant }}" data-size="{{ size }}"
        {%- if value is not none %} data-copy-text="{{ value }}"{% endif %}
        {%- if copy_target %} data-copy-target="{{ copy_target }}"{% endif %}
        data-copied-label="{{ copied_label }}"
        class="{{ base }} {{ variants[variant] }} {{ sizes[size] }} {{ extra_class }}"
        {%- if disabled %} disabled{% endif %}
        {%- if aria_label %} aria-label="{{ aria_label }}"{% elif icon_only %} aria-label="{{ label }}"{% endif %}
        {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
  <svg data-copy-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
    <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
  </svg>
  <svg data-copy-check xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M20 6 9 17l-5-5" />
  </svg>
  {%- if not icon_only %}<span data-copy-label>{{ label }}</span>{% endif %}
  <span class="sr-only" aria-live="{{ live }}" data-copy-status></span>
</button>
{% endmacro %}

1. Save the file

Add copy-button.tmpl alongside your templates.

2. Use it

components/copy-button.tmpl
tpl.ExecuteTemplate(w, "copy-button", map[string]any{
    "Value": "npm i shadcn-htmx",
})
View source
components/copy-button.tmpl
{{/*
  Copy Button template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/copy-button.tsx for Go projects using html/template.

  Click-to-copy: writes Value (or the text of the element named by CopyTarget)
  to the clipboard via the Async Clipboard API, then flips to a transient
  "Copied" state announced through an empty aria-live region. The shared
  behaviour ships in site.js. Sources:
    repos/mdn/files/en-us/web/api/clipboard/writetext/index.md
    repos/web.dev/src/site/content/en/patterns/clipboard/copy-text/index.md
    repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/index.md

  Usage:

      type CopyButtonArgs struct {
          Value       string            // text written to the clipboard
          CopyTarget  string            // OR: id of element whose text to copy
          Variant     string            // outline | ghost | secondary
          Size        string            // default | sm | icon
          Label       string            // visible label (default "Copy")
          CopiedLabel string            // success label (default "Copied")
          AriaLabel   string            // required for size="icon"
          Live        string            // polite | assertive
          Disabled    bool
          Attrs       map[string]string // hx-*, data-*, …
      }

      tpl.ExecuteTemplate(w, "copy-button", CopyButtonArgs{
          Value: "npm i shadcn-htmx",
      })

  Native <button> → role + Space/Enter activation come for free.
  See repos/aria-practices/content/patterns/button/.
*/}}

{{define "copy-button"}}
{{- $variants := dict
    "outline" "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
    "ghost" "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
    "secondary" "bg-secondary text-secondary-foreground hover:bg-secondary/80" -}}
{{- $sizes := dict
    "default" "h-8 px-2.5"
    "sm" "h-7 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3"
    "icon" "size-8 [&]:px-0" -}}
{{- $variant := or .Variant "outline" -}}
{{- $size := or .Size "default" -}}
{{- $label := or .Label "Copy" -}}
{{- $copiedLabel := or .CopiedLabel "Copied" -}}
{{- $live := or .Live "polite" -}}
{{- $iconOnly := eq $size "icon" -}}
{{- $base := "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 [&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex" -}}
<button type="button"
        data-slot="copy-button" data-variant="{{$variant}}" data-size="{{$size}}"
        {{- if .Value}} data-copy-text="{{.Value}}"{{end}}
        {{- if .CopyTarget}} data-copy-target="{{.CopyTarget}}"{{end}}
        data-copied-label="{{$copiedLabel}}"
        class="{{$base}} {{index $variants $variant}} {{index $sizes $size}}"
        {{- if .Disabled}} disabled{{end}}
        {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{else if $iconOnly}} aria-label="{{$label}}"{{end}}
        {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
  <svg data-copy-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
    <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
  </svg>
  <svg data-copy-check xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M20 6 9 17l-5-5" />
  </svg>
  {{- if not $iconOnly}}<span data-copy-label>{{$label}}</span>{{end}}
  <span class="sr-only" aria-live="{{$live}}" data-copy-status></span>
</button>
{{end}}

{{/*
  Note: this template uses sprig's `dict` helper. If you don't use sprig,
  hard-code the class lookup or pass the class string from Go:

      args.Class = computeCopyButtonClass(args.Variant, args.Size)

  and reference {{.Class}} directly in the template.
*/}}

1. Save the file

Drop copy_button.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/copy_button.ex
alias ShadcnHtmx.Components.CopyButton

<CopyButton.copy_button value="npm i shadcn-htmx" />
View source
lib/my_app_web/components/copy_button.ex
defmodule ShadcnHtmx.Components.CopyButton do
  @moduledoc """
  Copy Button — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/copy-button.tsx so a Phoenix LiveView project renders the
  same markup our docs site renders. Works with plain HEEx too — htmx attributes
  pass through via `:rest`.

  Click-to-copy: writes `value` (or the text of the element named by
  `copy_target`) to the clipboard via the Async Clipboard API, then flips to a
  transient "Copied" state announced through an empty aria-live region. The
  shared behaviour ships in site.js. Sources:

    * repos/mdn/files/en-us/web/api/clipboard/writetext/index.md
    * repos/web.dev/src/site/content/en/patterns/clipboard/copy-text/index.md
    * repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/index.md

  ## Examples

      <.copy_button value="npm i shadcn-htmx" />
      <.copy_button copy_target="api-key" size="icon" aria-label="Copy API key" />

  Native `<button>` → role and Space/Enter activation come for free.
  See repos/aria-practices/content/patterns/button/.
  """

  use Phoenix.Component

  @variants %{
    "outline" =>
      "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
    "ghost" => "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
    "secondary" => "bg-secondary text-secondary-foreground hover:bg-secondary/80"
  }

  @sizes %{
    "default" => "h-8 px-2.5",
    "sm" => "h-7 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
    "icon" => "size-8 [&]:px-0"
  }

  @base "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium " <>
          "whitespace-nowrap transition-all outline-none " <>
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
          "disabled:pointer-events-none disabled:opacity-50 " <>
          "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 " <>
          "[&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 " <>
          "[&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex"

  attr :value, :string, default: nil
  attr :copy_target, :string, default: nil

  attr :variant, :string, default: "outline", values: ~w(outline ghost secondary)
  attr :size, :string, default: "default", values: ~w(default sm icon)

  attr :label, :string, default: "Copy"
  attr :copied_label, :string, default: "Copied"
  attr :live, :string, default: "polite", values: ~w(polite assertive)
  attr :disabled, :boolean, default: false
  attr :class, :string, default: nil

  attr :rest, :global,
    include:
      ~w(hx-get hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-vals
         id name value form aria-label aria-labelledby aria-describedby)

  def copy_button(assigns) do
    assigns =
      assigns
      |> assign(:variant_class, Map.fetch!(@variants, assigns.variant))
      |> assign(:size_class, Map.fetch!(@sizes, assigns.size))
      |> assign(:base_class, @base)
      |> assign(:icon_only, assigns.size == "icon")

    ~H"""
    <button
      type="button"
      data-slot="copy-button"
      data-variant={@variant}
      data-size={@size}
      data-copy-text={@value}
      data-copy-target={@copy_target}
      data-copied-label={@copied_label}
      class={[@base_class, @variant_class, @size_class, @class]}
      disabled={@disabled}
      aria-label={@rest[:"aria-label"] || if(@icon_only, do: @label, else: nil)}
      {@rest}
    >
      <svg
        data-copy-icon
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
        <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
      </svg>
      <svg
        data-copy-check
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M20 6 9 17l-5-5" />
      </svg>
      <span :if={!@icon_only} data-copy-label>{@label}</span>
      <span class="sr-only" aria-live={@live} data-copy-status></span>
    </button>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/copy-button.html
<!-- Paste straight into your page. The inline <script> at the
     bottom of the snippet ships the copy behaviour (delete it if
     you already load site.js). -->
<button type="button" data-slot="copy-button"
        data-copy-text="npm i shadcn-htmx" data-copied-label="Copied"
        class="inline-flex items-center gap-1.5 rounded-md border bg-background
               h-8 px-2.5 text-sm font-medium …">
  <svg data-copy-icon></svg>
  <svg data-copy-check></svg>
  <span data-copy-label>Copy</span>
  <span class="sr-only" aria-live="polite" data-copy-status></span>
</button>
View source
snippets/copy-button.html
<!--
  shadcn-htmx — raw Copy Button snippet.

  Click-to-copy: writes data-copy-text (or the text of the element named by
  data-copy-target) to the clipboard via the Async Clipboard API, then flips to
  a transient "Copied" state announced through the empty aria-live region.

  Built on:
    - navigator.clipboard.writeText(text) — returns a Promise, secure-context
      only (HTTPS / localhost), window must have focus.
        repos/mdn/files/en-us/web/api/clipboard/writetext/index.md
    - Progressive-enhancement fallback (throwaway <textarea> + execCommand)
      for non-secure contexts / older browsers.
        repos/web.dev/src/site/content/en/patterns/clipboard/copy-text/index.md
    - aria-live on an empty element, populated on success, so AT announces the
      change without moving focus.
        repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/index.md

  Requirements:
    1. Tailwind CSS v4 (or the Play CDN for quick experiments):
         <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    2. The shadcn theme CSS variables (--background, --border, --ring, …) —
       copy the :root / .dark blocks from app/styles/input.css.

  The inline <script> at the bottom is the SAME behaviour shipped in site.js,
  scoped to [data-slot="copy-button"]. If you already load site.js, delete it.
-->

<!-- Default (outline) — copies the literal string in data-copy-text -->
<button type="button" data-slot="copy-button" data-variant="outline" data-size="default"
  data-copy-text="npm i shadcn-htmx" data-copied-label="Copied"
  class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 [&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 px-2.5">
  <svg data-copy-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
  </svg>
  <svg data-copy-check xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M20 6 9 17l-5-5" />
  </svg>
  <span data-copy-label>Copy</span>
  <span class="sr-only" aria-live="polite" data-copy-status></span>
</button>

<!-- Icon-only (ghost) — accessible name from aria-label, no visible text -->
<button type="button" data-slot="copy-button" data-variant="ghost" data-size="icon"
  data-copy-text="sk_live_51H8xExampleKeyDoNotUse" data-copied-label="Copied" aria-label="Copy API key"
  class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 [&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 size-8 [&]:px-0">
  <svg data-copy-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
  </svg>
  <svg data-copy-check xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M20 6 9 17l-5-5" />
  </svg>
  <span class="sr-only" aria-live="polite" data-copy-status></span>
</button>

<!-- Copy from another element: data-copy-target points at an id; we copy that
     element's value (form fields) or textContent. -->
<input id="share-url" type="text" readonly value="https://shadcn-htmx.dev/r/copy-button.json"
  class="h-8 rounded-md border bg-background px-2.5 text-sm" />
<button type="button" data-slot="copy-button" data-variant="secondary" data-size="default"
  data-copy-target="share-url" data-copied-label="Copied"
  class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&[data-copied=true]]:text-emerald-600 dark:[&[data-copied=true]]:text-emerald-400 [&_[data-copy-check]]:hidden [&[data-copied=true]_[data-copy-icon]]:hidden [&[data-copied=true]_[data-copy-check]]:inline-flex bg-secondary text-secondary-foreground hover:bg-secondary/80 h-8 px-2.5">
  <svg data-copy-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
  </svg>
  <svg data-copy-check xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M20 6 9 17l-5-5" />
  </svg>
  <span data-copy-label>Copy URL</span>
  <span class="sr-only" aria-live="polite" data-copy-status></span>
</button>

<!--
  Behaviour (delete if you load site.js, which already ships this scoped to
  [data-slot="copy-button"]). Async Clipboard API with execCommand fallback.
-->
<script>
  (function () {
    var COPIED_MS = 2000
    function textFor(btn) {
      var targetId = btn.getAttribute('data-copy-target')
      if (targetId) {
        var el = document.getElementById(targetId)
        if (el) return 'value' in el && el.value != null ? el.value : (el.textContent || '')
      }
      return btn.getAttribute('data-copy-text') || ''
    }
    function legacyCopy(text) {
      var ta = document.createElement('textarea')
      ta.value = text
      ta.setAttribute('readonly', '')
      ta.style.position = 'fixed'
      ta.style.opacity = '0'
      document.body.appendChild(ta)
      ta.focus()
      ta.select()
      var ok = false
      try { ok = document.execCommand('copy') } catch (e) { ok = false }
      document.body.removeChild(ta)
      return ok
    }
    function flash(btn) {
      var copied = btn.getAttribute('data-copied-label') || 'Copied'
      var status = btn.querySelector('[data-copy-status]')
      var label = btn.querySelector('[data-copy-label]')
      var prevLabel = label ? label.textContent : null
      btn.setAttribute('data-copied', 'true')
      if (label) label.textContent = copied
      if (status) status.textContent = copied
      if (btn._copyTimer) clearTimeout(btn._copyTimer)
      btn._copyTimer = setTimeout(function () {
        btn.removeAttribute('data-copied')
        if (label && prevLabel != null) label.textContent = prevLabel
        if (status) status.textContent = ''
      }, COPIED_MS)
    }
    document.addEventListener('click', function (e) {
      var btn = e.target.closest && e.target.closest('[data-slot="copy-button"]')
      if (!btn || btn.disabled) return
      var text = textFor(btn)
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(text).then(
          function () { flash(btn) },
          function () { if (legacyCopy(text)) flash(btn) }
        )
      } else if (legacyCopy(text)) {
        flash(btn)
      }
    })
  })()
</script>

Examples

Copy a string

Click. The string in value is written to the clipboard; the button flips to a green check + "Copied" for two seconds, then resets.

The Async Clipboard API's navigator.clipboard.writeText() returns a Promise that resolves once the system clipboard has been updated — no third-party library, no execCommand hack in the happy path. It only works in a secure context (HTTPS or localhost) and from a window that has focus, so site.js falls back to a throwaway <textarea> when the API is missing.

npm i shadcn-htmx
<CopyButton value="npm i shadcn-htmx" />
{{ copy_button("npm i shadcn-htmx") }}
{{template "copy-button" (dict "Value" "npm i shadcn-htmx")}}
<CopyButton.copy_button value="npm i shadcn-htmx" />
<div class="flex flex-wrap items-center justify-center gap-3 p-6">
  <button type="button" data-slot="copy-button" data-variant="outline" data-size="default" data-copy-text="npm i shadcn-htmx" data-copied-label="Copied" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 px-2.5">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span data-copy-label="true">Copy</span>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
  <code class="rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">npm i shadcn-htmx</code>
</div>

Variants, sizes & icon-only

Three quiet variants (outline, ghost, secondary) and an icon-only size that drops the label and takes its accessible name from ariaLabel.

A copy button is auxiliary chrome, so the variants stay understated. The icon-only size has no visible text — pass ariaLabel so screen-reader users still get a name. Either way the success announcement comes from the empty aria-live region, not from swapping the label.

<CopyButton value="…" variant="outline" />
<CopyButton value="…" variant="ghost" />
<CopyButton value="…" variant="secondary" />
<CopyButton value="…" size="sm" />
<CopyButton value="sk_live_…" size="icon" variant="ghost" ariaLabel="Copy API key" />
{{ copy_button("…", variant="outline") }}
{{ copy_button("…", variant="ghost") }}
{{ copy_button("…", variant="secondary") }}
{{ copy_button("…", size="sm") }}
{{ copy_button("sk_live_…", size="icon", variant="ghost", aria_label="Copy API key") }}
{{template "copy-button" (dict "Value" "…" "Variant" "outline")}}
{{template "copy-button" (dict "Value" "…" "Variant" "ghost")}}
{{template "copy-button" (dict "Value" "…" "Variant" "secondary")}}
{{template "copy-button" (dict "Value" "…" "Size" "sm")}}
{{template "copy-button" (dict
  "Value" "sk_live_…" "Size" "icon" "Variant" "ghost" "AriaLabel" "Copy API key"
)}}
<CopyButton.copy_button value="…" variant="outline" />
<CopyButton.copy_button value="…" variant="ghost" />
<CopyButton.copy_button value="…" variant="secondary" />
<CopyButton.copy_button value="…" size="sm" />
<CopyButton.copy_button value="sk_live_…" size="icon" variant="ghost" aria-label="Copy API key" />
<div class="flex flex-wrap items-center justify-center gap-3 p-6">
  <button type="button" data-slot="copy-button" data-variant="outline" data-size="default" data-copy-text="outline" data-copied-label="Copied" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 px-2.5">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span data-copy-label="true">Copy</span>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
  <button type="button" data-slot="copy-button" data-variant="ghost" data-size="default" data-copy-text="ghost" data-copied-label="Copied" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 px-2.5">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span data-copy-label="true">Copy</span>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
  <button type="button" data-slot="copy-button" data-variant="secondary" data-size="default" data-copy-text="secondary" data-copied-label="Copied" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex bg-secondary text-secondary-foreground hover:bg-secondary/80 h-8 px-2.5">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span data-copy-label="true">Copy</span>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
  <button type="button" data-slot="copy-button" data-variant="outline" data-size="sm" data-copy-text="small" data-copied-label="Copied" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-7 gap-1 px-2 text-xs [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span data-copy-label="true">Copy</span>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
  <button type="button" data-slot="copy-button" data-variant="ghost" data-size="icon" data-copy-text="sk_live_51H8xExampleKeyDoNotUse" data-copied-label="Copied" aria-label="Copy API key" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 size-8 [&amp;]:px-0">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
</div>

Copy from another element

Instead of a literal value, point copyTarget at an element's id. The button copies that element's value (form fields) or textContent — handy next to a read-only input or a code block.

Pass copyTarget the id of an element and the button reads its live text at click time — so the source of truth stays in one place. This is exactly how the docs site's own code-block wires its copy affordance.

<input id="share-url" type="text" readonly value="https://…" />
<CopyButton copyTarget="share-url" variant="secondary" label="Copy URL" />
<input id="share-url" type="text" readonly value="https://…" />
{{ copy_button(copy_target="share-url", variant="secondary", label="Copy URL") }}
<input id="share-url" type="text" readonly value="https://…" />
{{template "copy-button" (dict
  "CopyTarget" "share-url" "Variant" "secondary" "Label" "Copy URL"
)}}
<input id="share-url" type="text" readonly value="https://…" />
<CopyButton.copy_button copy_target="share-url" variant="secondary" label="Copy URL" />
<div class="flex flex-wrap items-center justify-center gap-3 p-6">
  <label class="sr-only" for="ex-share-url">Share URL</label>
  <input id="ex-share-url" type="text" readonly="" value="https://shadcn-htmx.dev/r/copy-button.json" class="h-8 w-72 max-w-full rounded-md border bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"/>
  <button type="button" data-slot="copy-button" data-variant="secondary" data-size="default" data-copy-target="ex-share-url" data-copied-label="Copied" class="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-3.5 [&amp;[data-copied=true]]:text-emerald-600 dark:[&amp;[data-copied=true]]:text-emerald-400 [&amp;_[data-copy-check]]:hidden [&amp;[data-copied=true]_[data-copy-icon]]:hidden [&amp;[data-copied=true]_[data-copy-check]]:inline-flex bg-secondary text-secondary-foreground hover:bg-secondary/80 h-8 px-2.5">
    <svg data-copy-icon="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <rect width="14" height="14" x="8" y="8" rx="2" ry="2">
      </rect>
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
      </path>
    </svg>
    <svg data-copy-check="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5">
      </path>
    </svg>
    <span data-copy-label="true">Copy URL</span>
    <span class="sr-only" aria-live="polite" data-copy-status="true">
    </span>
  </button>
</div>

Further reading

API Reference

<CopyButton>

All hx-*, data-* and aria-* attributes are forwarded onto the underlying <button> via ...rest.

PropTypeDefaultDescription
valuestring
The string written to the clipboard. Pass this OR copyTarget; if both are set, copyTarget wins.MDNClipboard.writeText()
copyTargetstring
Id of an element whose live value (form fields) or textContent is copied at click time. Lets the source of truth live elsewhere (e.g. a read-only input or code block).MDNNode.textContent
variant"outline"|"ghost"|"secondary""outline"
Visual style. All three are understated since a copy button is auxiliary chrome.
size"default"|"sm"|"icon""default"
Size variant. icon is square with no visible label — pass ariaLabel for an accessible name.
labelstring"Copy"
Visible label next to the icon. Dropped for size="icon".
copiedLabelstring"Copied"
Label + announcement shown for two seconds after a successful copy.
live"polite"|"assertive""polite"
Politeness of the success announcement written into the empty aria-live region. polite waits for a graceful pause; assertive interrupts.MDNaria-live
disabledbooleanfalse
Disable the button — skipped from tab order, no copy on click.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference