A button that copies text to the clipboard and announces that it did.
npm install @syncclouds/uiView codeHide code
'use client'
import { CopyButton } from '@syncclouds/ui'
export function CopyButtonDemo() {
return (
<div className="flex items-center gap-3">
<code className="font-mono text-sm">npm install @syncclouds/ui</code>
<CopyButton value="npm install @syncclouds/ui">
{({ copied }) => (copied ? 'Copied' : 'Copy')}
</CopyButton>
</div>
)
}Installation
Available at launch
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uiAvailable at launch
pnpm dlx shadcn@latest add https://ui.syncclouds.com/r/copy-button.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/copy-button.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/copy-button.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/copy-button.jsonThe CLI copies one file into your ui folder.
1. Copy these files into one folder
copy-button.tsxShowHide
'use client'
import {
useEffect,
useRef,
useState,
type ComponentPropsWithRef,
type CSSProperties,
type ReactNode
} from 'react'
const VISUALLY_HIDDEN: CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
border: 0
}
/* Older browsers and insecure origins have no async clipboard. */
const fallbackCopy = (text: string) => {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', '')
Object.assign(area.style, { position: 'fixed', opacity: '0' })
document.body.append(area)
area.select()
document.execCommand('copy')
area.remove()
}
export interface CopyButtonProps
extends Omit<ComponentPropsWithRef<'button'>, 'value' | 'children'> {
/** The text to copy, or a function that returns it at the moment of the click. */
value: string | (() => string)
onCopied?: () => void
/** "Copied" is announced in a polite live region; change the words here. */
announcement?: string
children?: ReactNode | ((state: { copied: boolean }) => ReactNode)
}
/**
* Copies `value` (or what it returns) to the clipboard. For two seconds afterwards it carries
* `data-state="copied"`, and a live region announces the copy.
*/
export function CopyButton({
value,
onCopied,
announcement = 'Copied',
onClick,
children,
...props
}: CopyButtonProps) {
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
const text = typeof value === 'function' ? value() : value
try {
await navigator.clipboard.writeText(text)
} catch {
fallbackCopy(text)
}
setCopied(true)
onCopied?.()
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 2000)
}
return (
<>
<button
type="button"
{...props}
data-scope="copy-button"
data-part="root"
data-state={copied ? 'copied' : 'idle'}
onClick={event => {
onClick?.(event)
if (!event.defaultPrevented) void copy()
}}
>
{typeof children === 'function' ? children({ copied }) : children}
</button>
<span role="status" style={VISUALLY_HIDDEN}>
{copied ? announcement : ''}
</span>
</>
)
}
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import { CopyButton } from '@syncclouds/ui'<CopyButton value="npm install @syncclouds/ui">
{({ copied }) => (copied ? 'Copied' : 'Copy')}
</CopyButton>Anatomy
CopyButton
└── (live region, rendered for you)Icon only
A button with no visible text needs an aria-label.
View codeHide code
'use client'
import { CopyButton } from '@syncclouds/ui'
const CopyIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h9" />
</svg>
)
const CheckIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
)
/* With no visible text, the button needs an aria-label. */
export function CopyButtonIcon() {
return (
<CopyButton value="https://ui.syncclouds.com" aria-label="Copy link">
{({ copied }) => (copied ? <CheckIcon /> : <CopyIcon />)}
</CopyButton>
)
}Custom announcement
Say what was copied, so a screen reader user does not have to guess.
View codeHide code
'use client'
import { CopyButton } from '@syncclouds/ui'
export function CopyButtonAnnouncement() {
return (
<CopyButton value="INV-2026-0142" announcement="Invoice number copied">
{({ copied }) => (copied ? 'Copied' : 'Copy invoice number')}
</CopyButton>
)
}Without the theme
View codeHide code
'use client'
import { CopyButton } from '@syncclouds/ui'
export function CopyButtonUnstyled() {
return (
<CopyButton
value="sk_test_4eC39HqLyjWDarjtT1zdp7dc"
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 data-[state=copied]:bg-emerald-700 dark:bg-zinc-100 dark:text-zinc-900 dark:data-[state=copied]:bg-emerald-300"
>
{({ copied }) => (copied ? 'Key copied' : 'Copy test key')}
</CopyButton>
)
}Accessibility
| Keys | What they do |
|---|---|
| Enter, Space | Copies the text. |
- An icon-only button needs an aria-label, such as "Copy command".
- Where the async clipboard is not available, it falls back to a hidden text field and the copy command.
API reference
Every part also accepts the props of the element it renders, including className and ref.
CopyButtonrenders <button>
Copies text, then carries data-state="copied" for two seconds. A polite live region next to it announces the copy.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | (() => string) | none | Required. The text to copy, or a function that returns it at the moment of the click. |
onCopied | () => void | none | Called after the text is copied. |
announcement | string | "Copied" | What the live region says after a copy. |
children | ReactNode | ((state: { copied: boolean }) => ReactNode) | none | The label, or a function that swaps it while copied. |
| Data attribute | Values |
|---|---|
data-scope | "copy-button" |
data-state | "idle" | "copied" |