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.json2. Use it
import { CopyButton } from "@/components/ui/copy-button"
<CopyButton value="npm i shadcn-htmx" />Or copy the source manually
/** @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
{% from "components/copy-button.html" import copy_button %}
{{ copy_button("npm i shadcn-htmx") }}View source
{# 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
tpl.ExecuteTemplate(w, "copy-button", map[string]any{
"Value": "npm i shadcn-htmx",
})View source
{{/*
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
alias ShadcnHtmx.Components.CopyButton
<CopyButton.copy_button value="npm i shadcn-htmx" />View source
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
<!-- 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
<!--
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 [&_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="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>Further reading
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 [&_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="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 [&_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 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 [&_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="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 [&_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-7 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]: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 [&_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="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>Further reading
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 [&_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="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.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The string written to the clipboard. Pass this OR copyTarget; if both are set, copyTarget wins.MDNClipboard.writeText() |
copyTarget | string | — | 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. |
label | string | "Copy" | Visible label next to the icon. Dropped for size="icon". |
copiedLabel | string | "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 |
disabled | boolean | false | Disable the button — skipped from tab order, no copy on click. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |